Simple Mode
For a developer protecting one service for one customer, or a small product that does not need apps, spaces or business-action names. Simple mode is the least you can run: one platform document, requests named by HTTP method and path, and no route map. This page lists what you need, gives a document that works, and says when to move on.
What you need
| Simple mode | |
|---|---|
| Dependencies | The Quick start's |
| Configuration | spring.data.redis.* and cosec.jwt.secret. cosec.authorization.remote-ip.max-trusted-index: 0 if clients reach the service directly. Nothing for apps, spaces or a route map. |
| Files | platform.json; cosec-migrate publish writes it as the first revision |
| Redis | maxmemory-policy noeviction |
| Concepts | The platform document, policies, statements, bindings to everyone ("subject": "*"), the http namespace, default deny, deny first, the default tenant (0) |
| Left out | Apps and spaces, route maps, catalogs, role entries, boundaries, rate limits, shadow windows |
Every request is http:<METHOD> on http:path:<path>. A token carries no tenant, so its principal is in the default tenant (0), and no app, so no app document is read.
The document
{
"schema": 2,
"revision": 1,
"publishedAt": "2026-11-01T08:00:00Z",
"policies": [
{
"id": "login",
"statements": [
{
"name": "login",
"effect": "allow",
"action": "http:POST",
"resource": "http:path:/auth/login"
}
]
},
{
"id": "control-api",
"statements": [
{
"name": "reach-the-control-api",
"effect": "allow",
"action": [
"http:GET",
"http:POST",
"http:DELETE"
],
"resource": "http:path:/cosec/control/v1/**",
"condition": {
"eq": [
"subject.authenticated",
true
]
}
}
]
},
{
"id": "public",
"statements": [
{
"name": "hello",
"effect": "allow",
"action": "http:GET",
"resource": "http:path:/hello"
}
]
},
{
"id": "members",
"statements": [
{
"name": "read-orders",
"effect": "allow",
"action": "http:GET",
"resource": "http:path:/orders/**",
"condition": {
"eq": [
"subject.authenticated",
true
]
}
}
]
},
{
"id": "admins",
"statements": [
{
"name": "admin-area",
"effect": "allow",
"action": "http:*",
"resource": "http:path:/admin/**",
"condition": {
"in": [
"admin",
"scope.roles"
]
}
}
]
},
{
"id": "no-delete",
"statements": [
{
"name": "orders-are-kept",
"effect": "deny",
"action": "http:DELETE",
"resource": "http:path:/orders/**"
}
]
}
],
"bindings": [
{
"id": "login",
"subject": "*",
"app": "*",
"space": "*",
"policy": "login"
},
{
"id": "control-api",
"subject": "*",
"app": "*",
"space": "*",
"policy": "control-api"
},
{
"id": "public",
"subject": "*",
"app": "*",
"space": "*",
"policy": "public"
},
{
"id": "members",
"subject": "*",
"app": "*",
"space": "*",
"policy": "members"
},
{
"id": "admins",
"subject": "*",
"app": "*",
"space": "*",
"policy": "admins"
},
{
"id": "no-delete",
"subject": "*",
"app": "*",
"space": "*",
"policy": "no-delete"
}
]
}publiclets anyoneGET /hello, signed in or not;logindoes the same forPOST /auth/login.memberslets a signed-in callerGET /orders/**.adminslets a principal with the roleadmindo anything under/admin/**. In simple mode the token's roles are roles of the default space, soscope.rolesis the token'srolesclaim. Usescope.rolesto authorize and notsubject.roles, which also counts roles held in other spaces (Roles in conditions).no-deleteforbidsDELETE /orders/**for everyone. A matching DENY wins over every ALLOW,adminincluded.control-apilets any signed-in caller reach the control API's paths. The control plane authorizes each step again as the caller, so this opens a path, not a permission. Without it you could never change this document through the control plane; see Change a document later.
Issue a token with a role when the user signs in:
TokenSubject(id = user.id, app = null, roles = setOf("admin"))Check it
The scenarios in wiki/examples/simple/scenarios run against these documents on every build, with the test kit:
| Request | Caller | Decision |
|---|---|---|
GET /hello | anonymous | Permit |
GET /orders/1 | anonymous | Deny: 401 |
GET /orders/1 | signed in | Permit |
DELETE /orders/1 | role admin | Deny: the DENY wins |
GET /admin/users | role admin | Permit |
GET /admin/users | signed in, no role | Deny: 403 |
Many roles
A condition per role does not scale past a handful. The default tenant is a tenant like any other, so it can hold role bindings: leave admins unbound in the platform document and put the binding in tenants/(0).json (the file name is URL-encoded, tenants/%280%29.json):
{
"schema": 2,
"revision": 1,
"publishedAt": "2026-11-01T08:00:00Z",
"bindings": [
{
"id": "editors-edit",
"subject": {
"role": "editor"
},
"app": "*",
"space": "*",
"policy": "editors"
},
{
"id": "admins-administer",
"subject": {
"role": "admin"
},
"app": "*",
"space": "*",
"policy": "admins"
}
]
}A role binding here applies to principals holding the role, and a binding to "*" in this document also applies to anonymous callers, because they are in (0) too. The complete example, with the platform document and its scenarios, is in wiki/examples/simple-roles.
Limits of simple mode
- The platform document cannot bind a role. It is shared by every tenant, so a role there would mean the same thing to all of them. Use the condition above, or the
(0)document. - Role changes take effect when a user next logs in, as roles travel in the token (Roles and spaces).
- Permissions are written as statements. If you want them listed in a console, or granted to roles by name, move to an app document: An app without spaces.
Next: an app with permissions and roles, in An app without spaces.