|
1 | | -# Structure of CalmHub permissions system |
| 1 | +# CalmHub Permissions — Developer Reference |
2 | 2 |
|
3 | | -CalmHub drives its permission system from the in-memory database. |
4 | | -Entitlements are stored as `UserAccess` records. |
| 3 | +For user-facing documentation, see [`docs/docs/working-with-calm/calm-hub-entitlements.md`](../docs/docs/working-with-calm/calm-hub-entitlements.md). |
5 | 4 |
|
6 | | -## Structure of entitlements model |
| 5 | +--- |
7 | 6 |
|
8 | | -Entitlements are applied at a per-namespace level, at domain level for control requirements and configurations. |
| 7 | +## Storage model |
9 | 8 |
|
10 | | -The available actions are the following. |
| 9 | +Entitlements are stored as `UserAccess` records (see `domain/UserAccess.java`). Each record has: |
| 10 | +- `username` — a real username from the OIDC provider, or `*` for the public-access wildcard |
| 11 | +- `permission` — `read`, `write`, or `admin` |
| 12 | +- `namespace` — the namespace this grant applies to (mutually exclusive with `domain`) |
| 13 | +- `domain` — the control domain this grant applies to (mutually exclusive with `namespace`) |
11 | 14 |
|
12 | | -| Action | Description | |
13 | | -|---------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |
14 | | -| `read` | Can read any documents of that type in the namespace. | |
15 | | -| `write` | Can write any documents of that type in the namespace. This includes deleting them. Note that by default resources in CalmHub are immutable, so this usually means 'create' only. | |
16 | | -| `admin` | Can do anything to all resource types, and also grant entitlements to other users in the namespace. | |
| 15 | +The `*` wildcard is a valid username value (per `USERNAME_REGEX` in `ResourceValidationConstants`). It represents any user and is evaluated by the same logic as named user grants. |
17 | 16 |
|
18 | | -For example, `read` means the user can read all resources in that NS. |
| 17 | +--- |
19 | 18 |
|
20 | | -Please note that each entitlement implies all previous levels - i.e. `write` implies `read`. |
21 | | -`admin` implies `read` and `write` on all resource types. |
| 19 | +## Permission levels |
22 | 20 |
|
23 | | -## Global admin |
| 21 | +| Permission | Implies | |
| 22 | +|------------|---------| |
| 23 | +| `read` | — | |
| 24 | +| `write` | `read` | |
| 25 | +| `admin` | `read`, `write` | |
24 | 26 |
|
25 | | -Some resources aren't tied to any one namespace. |
26 | | -Creating namespaces and managing core schemas requires the `admin` role, with the namespace `GLOBAL` in the database. |
| 27 | +--- |
27 | 28 |
|
28 | | -**NOTE**: Global admin also gives you the right to manage domains. |
29 | | -There's only one notion of global admin. |
| 29 | +## Hierarchical namespace rules |
30 | 30 |
|
31 | | -## Global READ mode |
| 31 | +Namespaces use `.` as a separator (`org`, `org.ab`, `org.ab.cd`). The ancestor chain for a namespace is the list of all prefixes, inclusive of the namespace itself. |
32 | 32 |
|
33 | | -It's possible to configure CalmHub to grant `read` to all users by default. |
| 33 | +### READ — AND across ancestor chain |
34 | 34 |
|
35 | | -To do this, set the property `calm.auth.allow-public-read=true`. |
36 | | -By default this property is `false`. |
| 35 | +``` |
| 36 | +canRead(username, namespace): |
| 37 | + grants = store.getGrantsForUser(username) // user grants + * grants in one query |
| 38 | + ancestors = ancestorChain(namespace) // ["org", "org.ab", "org.ab.cd"] for "org.ab.cd" |
| 39 | + return ancestors.ALL(level → |
| 40 | + grants.ANY(g → g.namespace == level && permissionSufficient(g, READ))) |
| 41 | +``` |
37 | 42 |
|
| 43 | +**Every** level must have a matching grant. This allows a parent to be public while a child is restricted. |
| 44 | + |
| 45 | +### WRITE / ADMIN — OR across ancestor chain |
| 46 | + |
| 47 | +``` |
| 48 | +canWrite(username, namespace): |
| 49 | + grants = store.getGrantsForUser(username) |
| 50 | + ancestors = ancestorChain(namespace) |
| 51 | + return ancestors.ANY(level → |
| 52 | + grants.ANY(g → g.namespace == level && permissionSufficient(g, WRITE))) |
| 53 | +``` |
| 54 | + |
| 55 | +**Any** ancestor (including the namespace itself) with a sufficient grant is enough. Grants at a parent cascade to all descendants for write/admin purposes. |
| 56 | + |
| 57 | +### `permissionSufficient` |
| 58 | + |
| 59 | +| Requested | Sufficient grant | |
| 60 | +|-----------|-----------------| |
| 61 | +| READ | `read`, `write`, or `admin` | |
| 62 | +| WRITE | `write` or `admin` | |
| 63 | +| ADMIN | `admin` only | |
| 64 | + |
| 65 | +--- |
| 66 | + |
| 67 | +## Key components |
| 68 | + |
| 69 | +| Class | Role | |
| 70 | +|-------|------| |
| 71 | +| `CalmHubPermissionChecker` | Implements the AND/OR hierarchical logic; calls `getGrantsForUser` | |
| 72 | +| `UserAccessStore` (interface) | `getGrantsForUser(username)` returns user + `*` grants in one query | |
| 73 | +| `MongoUserAccessStore` | Mongo implementation of `getGrantsForUser` | |
| 74 | +| `NitriteUserAccessStore` | Nitrite implementation of `getGrantsForUser` | |
| 75 | +| `UserAccessValidator` | `getReadableNamespaces(username)` — exact ancestor-chain filter used by search | |
| 76 | +| `NamespaceService` | Orchestrates namespace creation + automatic `* read` grant insertion | |
| 77 | +| `NamespaceMigrationService` | StartupEvent observer; backfills `* read` grants on pre-existing namespaces | |
| 78 | + |
| 79 | +--- |
| 80 | + |
| 81 | +## Special bypasses |
| 82 | + |
| 83 | +### `allow-public-read` config flag |
| 84 | + |
| 85 | +`calm.auth.allow-public-read=true` is a global bypass in `CalmHubPermissionChecker.canRead`. It short-circuits all namespace checks. Intended for fully-open deployments; default is `false`. |
| 86 | + |
| 87 | +### GLOBAL admin |
| 88 | + |
| 89 | +A user with `admin` on `GLOBAL` bypasses all namespace-level permission checks via `hasGlobalAdmin`. Domain access is also granted. `GLOBAL` is not part of the namespace hierarchy. |
| 90 | + |
| 91 | +--- |
| 92 | + |
| 93 | +## Default-open namespace behaviour |
| 94 | + |
| 95 | +When a namespace is created via `NamespaceService`, a `UserAccess("*", read, namespace)` record is inserted automatically. This keeps the hub open by default. |
| 96 | + |
| 97 | +To restrict a namespace: delete the `* read` record. The AND rule for child namespaces means restriction cascades automatically. |
| 98 | + |
| 99 | +--- |
| 100 | + |
| 101 | +## `getGrantsForUser` vs `getUserAccessForUsername` |
| 102 | + |
| 103 | +- **`getGrantsForUser(username)`** — returns grants where `username = :username OR username = '*'`. Used by the permission checker (single round-trip per request). |
| 104 | +- **`getUserAccessForUsername(username)`** — returns grants for exactly one username. Used by admin/management endpoints (`UserAccessResource`, `UserAccessValidator`) that need to inspect a single user's grants without `*` mixed in. |
| 105 | + |
| 106 | +--- |
| 107 | + |
| 108 | +## Domain-scoped access |
| 109 | + |
| 110 | +`hasDomainAccess`, `canReadByDomain`, etc. are **not** hierarchical — domain access remains flat. `UserAccess` records have either `namespace` or `domain` set, never both. |
0 commit comments