Skip to content

Commit e080631

Browse files
committed
added AGENTS.md & DOCS
1 parent e99b0bc commit e080631

3 files changed

Lines changed: 251 additions & 0 deletions

File tree

.gitattributes

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
.gitignore export-ignore
44
ncs.* export-ignore
55
phpstan*.neon export-ignore
6+
AGENTS.md export-ignore
67
src/**/*.latte export-ignore
8+
docs/ export-ignore
79
tests/ export-ignore
810

911
*.php* diff=php

AGENTS.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# To My Agents!
2+
3+
It is my fervent wish that this file guide every AI coding agent working with code in this repository.
4+
5+
## Documentation
6+
7+
Any distilled, agent-facing documentation for this package - how it works
8+
internally and the rationale behind key design decisions - lives in `docs/`.
9+
Consult it before non-trivial changes; it is the source of truth from which the
10+
public manual is distilled.
11+
12+
Almost all the expensive-to-reconstruct knowledge is in one subsystem - the
13+
`Permission` ACL resolution engine - plus a few `User` and storage invariants.
14+
This is security-critical: read `docs/internals.md` before touching
15+
authorization logic.
16+
17+
## Project Overview
18+
19+
Nette Security provides authentication (login/logout via pluggable
20+
`Authenticator`s), authorization (role-based ACL via `Permission`), and identity
21+
persistence (`UserStorage`: session or cookie). Standalone library. Developers
22+
**implement the interfaces, never extend the concrete classes**.
23+
24+
- **PHP Version**: 8.1 - 8.5
25+
- **Package**: `nette/security`
26+
27+
## Essential Commands
28+
29+
```bash
30+
# Run all tests
31+
vendor/bin/tester tests -s # or: composer tester
32+
vendor/bin/tester tests/Security.DI/ -s
33+
34+
# Static analysis (PHPStan level 8)
35+
composer phpstan
36+
```
37+
38+
## Conventions
39+
40+
- Every file starts with `declare(strict_types=1);`; **tabs**; single quotes unless
41+
the string has an apostrophe; no `Abstract`/`Interface`/`I` prefixes; Nette Coding
42+
Standard. Mark password parameters with `#[\SensitiveParameter]`.
43+
- Tests are Nette Tester `.phpt` (`tests/Security/`, `tests/Security.DI/`,
44+
`tests/Security.Http/`) plus PHPStan type tests in `tests/types/`;
45+
`Permission*.phpt` holds 40+ ACL scenarios.
46+
- Commit messages: lowercase imperative, no trailing period, `component: change`
47+
(e.g. `User: support for custom authenticators`).
48+
49+
## Working in this repo
50+
51+
- **`Permission` rule types are plain booleans, not an enum:** `Allow = true`,
52+
`Deny = false`, `All = null` (wildcard), and `getRuleType()` returns `?bool`
53+
(`null` = no rule). The default state is **deny-all (whitelist)**.
54+
- **`isAllowed()` resolves along three axes:** walk the resource tree (a more
55+
specific resource wins over its parents) x DFS the role DAG (the **most recently
56+
added parent has the highest weight**) x privilege (a specific privilege before
57+
`allPrivileges`). For a whole-resource query (`privilege = All`), **any single
58+
deny vetoes** the blanket allow.
59+
- **A failed `assert` callback makes the rule *not apply*** (falls through as if
60+
absent) - except on the ultimate default rule, where it returns the **inverted**
61+
type. Assertions receive string IDs and reach objects via
62+
`getQueriedRole()`/`getQueriedResource()`; `isAllowed()` is **not re-entrant** -
63+
it nulls the queried role/resource on return, so an assertion must read them
64+
before calling `isAllowed()` again.
65+
- **Effective roles come from login state, not the retained identity.** After
66+
`logout()` the identity is kept by default (`persistIdentity`), but roles drop to
67+
guest - which is why `isInRole()`/`isAllowed()` need no prior `isLoggedIn()` check
68+
and a retained identity never leaks its privileges.
69+
- **`loadStoredData()` runs once; `wakeupIdentity()` may return `null` to revoke
70+
auth** on a request (the per-request role-refresh/token seam); `sleepIdentity()`
71+
is its `login()`-time counterpart. **Switching the storage namespace requires
72+
`refreshStorage()`** or `User` serves stale authentication.
73+
- User-facing how-to (fluent ACL API, `IdentityHandler`/guest identity, password
74+
hashing, NEON config, presenter integration, multiple authenticators) is manual
75+
material and lives in the public web docs, not here.

docs/internals.md

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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

Comments
 (0)