|
| 1 | +# Security internals |
| 2 | + |
| 3 | +How `nette/security` works underneath, for agents editing it. Almost all the |
| 4 | +expensive-to-reconstruct knowledge lives in one subsystem — the `Permission` ACL |
| 5 | +resolution engine — so this is one file, weighted heavily toward it. `User`, |
| 6 | +`Authenticator`, `Identity`, and `UserStorage` are mostly readable from their |
| 7 | +signatures; only a few of their invariants are captured here. |
| 8 | + |
| 9 | +## Rule encoding: booleans and null, not an enum |
| 10 | + |
| 11 | +Three constants (`Authorizator`) permeate every branch and are easy to |
| 12 | +misread: |
| 13 | + |
| 14 | +- `All = null` — the "any role / any resource / any privilege" wildcard. |
| 15 | +- `Allow = true`, `Deny = false` — rule **types are plain booleans**. |
| 16 | + |
| 17 | +Consequently the internal `getRuleType()` returns **`?bool`**: `null` = "no rule |
| 18 | +found", `true` = allow, `false` = deny. The **default state is deny-all**: the |
| 19 | +root rule (`$rules['allResources']['allRoles']['allPrivileges']`) is seeded to |
| 20 | +`Deny`, so an ACL with no rules denies everything (whitelist model). |
| 21 | + |
| 22 | +## `$rules`: a three-axis nested map with string keys |
| 23 | + |
| 24 | +The entire ACL state is one nested array (`Permission::$rules`) indexed on |
| 25 | +three specificity axes: |
| 26 | + |
| 27 | +``` |
| 28 | +byResource[<res>] | allResources (resource axis) |
| 29 | + → byRole[<role>] | allRoles (role axis) |
| 30 | + → byPrivilege[<priv>] | allPrivileges (privilege axis) |
| 31 | + → { type: Allow|Deny, assert: ?callable } |
| 32 | +``` |
| 33 | + |
| 34 | +`getRules($resource, $role, $create)` navigates the first two axes **by |
| 35 | +reference** (so callers mutate in place). The `all*` string keys are magic |
| 36 | +sentinels that cannot collide with user IDs: user-supplied names live one level |
| 37 | +deeper, under the sibling `byResource`/`byRole`/`byPrivilege` subarrays, so even |
| 38 | +a role literally named `allRoles` lands in `byRole['allRoles']`. Roles and |
| 39 | +resources are stored as **string IDs**; the |
| 40 | +`Role`/`Resource` objects passed to `isAllowed()` are reduced to IDs immediately. |
| 41 | + |
| 42 | +## `isAllowed()`: the resolution algorithm (the emergent model) |
| 43 | + |
| 44 | +This is what you can only get by tracing. `isAllowed()` resolves a |
| 45 | +`(role, resource, privilege)` query by two nested traversals: |
| 46 | + |
| 47 | +1. **Resource axis — walk up the resource tree.** Starting at the queried |
| 48 | + resource, search the role DAG (step 2); if undecided, consult the `allRoles` |
| 49 | + pseudo-parent at this resource; then move to the resource's parent |
| 50 | + (`resources[$resource]['parent']`) and repeat, ending at `allResources`. |
| 51 | + **More specific resources win over their parents.** |
| 52 | +2. **Role axis — DFS over the role DAG** (`searchRolePrivileges`). A |
| 53 | + stack-based depth-first search from the queried role up through parents. Since |
| 54 | + parents are pushed in insertion order and popped last-first, **the most |
| 55 | + recently added parent has the highest weight** (`addRole('x', ['a','b'])` → `b` |
| 56 | + wins conflicts). A `$visited` set guards the DAG against diamond re-checks. |
| 57 | +3. **Privilege axis — specific before general.** For any (resource, role) pair, |
| 58 | + a rule on the exact `$privilege` is consulted before the `allPrivileges` rule. |
| 59 | + |
| 60 | +**Deny wins for whole-resource queries.** When the privilege is `All` (asking |
| 61 | +"may this role do *anything* here"), the search scans every `byPrivilege` rule |
| 62 | +and returns `Deny` if **any** of them denies, before falling back to the |
| 63 | +`allPrivileges` rule. A single specific deny vetoes the blanket allow. |
| 64 | + |
| 65 | +The first definitive `Allow`/`Deny` found in this order is returned; if the whole |
| 66 | +traversal finds nothing, the deny-all root answers. |
| 67 | + |
| 68 | +## Assertions: conditional rules, an inverted default, and queried-state lifetime |
| 69 | + |
| 70 | +A rule may carry an `assert` callback. In `getRuleType()`: |
| 71 | + |
| 72 | +- If the assertion returns **false, the rule does not apply** — it yields `null` |
| 73 | + and resolution falls through to the next candidate, exactly as if the rule were |
| 74 | + absent. This is the trap for anyone reading only static rules: a present rule |
| 75 | + can be silently skipped at query time. |
| 76 | +- **The one exception is the ultimate default rule** (all resources, all roles, |
| 77 | + all privileges). A failed assertion there has nothing left to fall back to, so |
| 78 | + it returns the **inverted** type (`Allow`→`Deny`, `Deny`→`Allow`) to force a |
| 79 | + definite answer. |
| 80 | +- Assertions receive **string IDs**, not objects. To reach the actual queried |
| 81 | + object they call `getQueriedRole()` / `getQueriedResource()`, which return |
| 82 | + whatever was passed to `isAllowed()`. |
| 83 | +- `isAllowed()` stores the queried role/resource in object state and |
| 84 | + unconditionally **nulls both on normal return**. There is **no save/restore |
| 85 | + and no `finally`**: a nested `isAllowed()` call from inside an assertion |
| 86 | + clobbers the queried state and leaves it `null` for the rest of the outer |
| 87 | + assertion, and an exception mid-query (unknown role/resource) leaves stale |
| 88 | + values behind. An assertion must read `getQueriedRole()`/`getQueriedResource()` |
| 89 | + **before** recursing into `isAllowed()`. |
| 90 | + |
| 91 | +## User: effective roles come from login state, not the retained identity |
| 92 | + |
| 93 | +`User` is a thin facade over `UserStorage` + an `Authenticator`/`Authorizator`. |
| 94 | +The few non-obvious invariants: |
| 95 | + |
| 96 | +- **Effective roles track the login state, not the identity object** |
| 97 | + (`getRoles()`). Logged in → the identity's roles (or `authenticatedRole`); |
| 98 | + **not** logged in → the guest identity's roles or `[guestRole]`. So after |
| 99 | + `logout()`, even though the identity is **retained** by default |
| 100 | + (`persistIdentity`), the user drops to guest roles. This is why |
| 101 | + `isInRole()`/`isAllowed()` need no prior `isLoggedIn()` check — and why a |
| 102 | + retained identity never leaks its privileges. |
| 103 | +- **State is loaded lazily once, and can be vetoed on load.** |
| 104 | + `loadStoredData()` runs a single time (guarded by `$authenticated !== null`), |
| 105 | + reads the storage state, and — if the authenticator is an `IdentityHandler` — |
| 106 | + passes the stored identity through **`wakeupIdentity()`, which may return |
| 107 | + `null` to revoke authentication** on this request. `wakeupIdentity` is the |
| 108 | + per-request role-refresh / token-revalidation seam; `sleepIdentity` is its |
| 109 | + counterpart at `login()` time (e.g. storing only a token for cookie storage). |
| 110 | +- **`refreshStorage()` discards the cached state.** Switching the storage |
| 111 | + **namespace** (multiple independent logins per session) without calling |
| 112 | + `refreshStorage()` leaves `User` serving stale authentication. |
| 113 | +- **Guest identity is resolved on read only and never written to storage**; |
| 114 | + its resolution is cached and invalidated by `setAuthenticator`/`refreshStorage`. |
| 115 | + It comes from `getGuestIdentity()` on the `IdentityHandler` authenticator — an |
| 116 | + **optional** method declared only as `@method` phpDoc and detected via |
| 117 | + `method_exists`, not part of the interface proper. |
| 118 | +- **`User::isAllowed()` is a disjunction over effective roles**: the user |
| 119 | + is allowed if **any single** of their roles is allowed — a deny resolved for |
| 120 | + one role never vetoes another role's allow. Single-role resolution lives in |
| 121 | + `Permission`; the multi-role OR lives only here. This is the opposite polarity |
| 122 | + of the deny-veto inside a whole-resource query, which applies per role. |
| 123 | + |
| 124 | +## Storages: session and cookie are not interchangeable |
| 125 | + |
| 126 | +`UserStorage::getState()` returns the tuple `[authenticated, identity, |
| 127 | +logoutReason]`; everything else differs between the two implementations: |
| 128 | + |
| 129 | +- **`SessionStorage` checks expiration lazily and it slides.** The check runs in |
| 130 | + `getSessionSection()` on first access: expired → `LogoutInactivity` (and the |
| 131 | + identity is dropped only if `expireIdentity` was set); not expired → |
| 132 | + `expireTime` is pushed forward by `expireDelta` (sliding window). Both |
| 133 | + `saveAuthentication()` **and** `clearAuthentication()` regenerate the session |
| 134 | + ID (session-fixation defence) — login/logout mutate the whole session, not just |
| 135 | + the `Nette.Http.UserStorage/<namespace>` section. `setNamespace()` drops the |
| 136 | + cached section; the `User`-side counterpart it needs is `refreshStorage()`. |
| 137 | +- **`CookieStorage` stores only `$identity->getId()`** and requires it to be at |
| 138 | + least 13 chars — i.e. a random token, never a database ID. `getState()` |
| 139 | + reconstructs a bare `SimpleIdentity` (no roles, no data), so cookie storage is |
| 140 | + usable only with an `IdentityHandler`: `sleepIdentity()` returns the token |
| 141 | + identity, `wakeupIdentity()` resolves it back to the real one. |
| 142 | + `clearAuthentication()` **ignores `$clearIdentity`** — the cookie is always |
| 143 | + deleted, so **`persistIdentity` has no effect with cookie storage**. |
| 144 | +- In the DI bridge (`SecurityExtension`), `cookieDomain: 'domain'` is a magic |
| 145 | + literal resolved at runtime to the request's second-level domain. |
| 146 | + |
| 147 | +## Legacy seams and BC constraints |
| 148 | + |
| 149 | +- **`User::login()` branches on `instanceof Authenticator`**: the modern |
| 150 | + interface gets `authenticate($username, $password)`, the deprecated |
| 151 | + `IAuthenticator` gets a single credentials array. `IAuthenticator::authenticate()` |
| 152 | + exists only as a commented-out declaration plus `@method` phpDoc — PHP does not |
| 153 | + enforce it, which is why `phpstan.neon` carries an ignore for `User.php`. |
| 154 | +- `compatibility.php` `class_alias`es the removed `IAuthorizator`/`IResource`/`IRole` |
| 155 | + names and is excluded from PHPStan analysis. |
| 156 | +- **`SimpleIdentity extends Identity` — the deprecated class is the parent**, |
| 157 | + kept so identities serialized in existing sessions keep unserializing. Don't |
| 158 | + flip the hierarchy or remove `Identity`. |
| 159 | +- `Identity::setId()` normalizes numeric strings to int only when the round-trip |
| 160 | + is lossless (`'123'` → `123`; `'0123'` stays a string). |
| 161 | + |
| 162 | +## Navigation map |
| 163 | + |
| 164 | +| Concern | Where | |
| 165 | +|---|---| |
| 166 | +| Rule constants, `?bool` typing | `Authorizator`, `Permission::getRuleType` | |
| 167 | +| ACL state shape | `Permission::$rules`, `getRules` | |
| 168 | +| Resolution order (resource × role × privilege) | `Permission::isAllowed`, `searchRolePrivileges` | |
| 169 | +| Assertions, queried role/resource | `Permission::getRuleType`, `getQueriedRole`/`getQueriedResource` | |
| 170 | +| Effective roles, login state, multi-role OR | `User::getRoles`, `isInRole`, `isAllowed` | |
| 171 | +| Identity lifecycle, veto, seams | `User::loadStoredData`, `login`/`logout`, `IdentityHandler` | |
| 172 | +| Sliding expiration, fixation defence, namespaces | `SessionStorage` | |
| 173 | +| Token-only persistence, `persistIdentity` no-op | `CookieStorage` | |
| 174 | +| Legacy authenticator dispatch, BC aliases | `User::login`, `IAuthenticator`, `compatibility.php` | |
0 commit comments