@plantops/auth-kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/dist/adapters/fetch/index.d.ts +38 -0
- package/dist/adapters/fetch/index.d.ts.map +1 -0
- package/dist/adapters/fetch/index.js +52 -0
- package/dist/adapters/nestjs/auth.guard.d.ts +120 -0
- package/dist/adapters/nestjs/auth.guard.d.ts.map +1 -0
- package/dist/adapters/nestjs/auth.guard.js +165 -0
- package/dist/adapters/nestjs/index.d.ts +10 -0
- package/dist/adapters/nestjs/index.d.ts.map +1 -0
- package/dist/adapters/nestjs/index.js +12 -0
- package/dist/adapters/nestjs/permission.guard.d.ts +115 -0
- package/dist/adapters/nestjs/permission.guard.d.ts.map +1 -0
- package/dist/adapters/nestjs/permission.guard.js +167 -0
- package/dist/adapters/nestjs/require-permission.decorator.d.ts +31 -0
- package/dist/adapters/nestjs/require-permission.decorator.d.ts.map +1 -0
- package/dist/adapters/nestjs/require-permission.decorator.js +41 -0
- package/dist/adapters/nestjs/scope-resolver.d.ts +12 -0
- package/dist/adapters/nestjs/scope-resolver.d.ts.map +1 -0
- package/dist/adapters/nestjs/scope-resolver.js +24 -0
- package/dist/core/claims.d.ts +114 -0
- package/dist/core/claims.d.ts.map +1 -0
- package/dist/core/claims.js +183 -0
- package/dist/core/index.d.ts +13 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +15 -0
- package/dist/core/jwks-verifier.d.ts +78 -0
- package/dist/core/jwks-verifier.d.ts.map +1 -0
- package/dist/core/jwks-verifier.js +183 -0
- package/dist/core/jws.d.ts +96 -0
- package/dist/core/jws.d.ts.map +1 -0
- package/dist/core/jws.js +183 -0
- package/dist/core/revocation-cache.d.ts +79 -0
- package/dist/core/revocation-cache.d.ts.map +1 -0
- package/dist/core/revocation-cache.js +68 -0
- package/dist/core/scope-resolver.d.ts +235 -0
- package/dist/core/scope-resolver.d.ts.map +1 -0
- package/dist/core/scope-resolver.js +206 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/tsconfig.lib.tsbuildinfo +1 -0
- package/package.json +64 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The revoked-`sid` cache (Doc 03 §6).
|
|
4
|
+
*
|
|
5
|
+
* Access tokens are verified by signature, which means a revoked session's
|
|
6
|
+
* token keeps verifying until it expires. That is unacceptable for the case the
|
|
7
|
+
* spec singles out — a shared gate terminal logged out at shift end — so every
|
|
8
|
+
* request also asks whether its `sid` has been killed. The answer has to be
|
|
9
|
+
* cheap enough to ask on every request, which rules out the database.
|
|
10
|
+
*
|
|
11
|
+
* ## One key per revocation, not one set
|
|
12
|
+
*
|
|
13
|
+
* Doc 03 §6 says "a Redis set / short-TTL cache". A set is the wrong half of
|
|
14
|
+
* that: it grows forever and has no per-member expiry, so it must be pruned by
|
|
15
|
+
* something, and the day that something stops running is the day the set is
|
|
16
|
+
* either unbounded or silently emptied. One key per revoked `sid`, with a TTL,
|
|
17
|
+
* prunes itself.
|
|
18
|
+
*
|
|
19
|
+
* The TTL is the **remaining exposure**, not the session lifetime. Once every
|
|
20
|
+
* token bearing a `sid` has expired, the revocation entry protects nothing:
|
|
21
|
+
* such a token is already refused for `exp`. So the entry lives for one access
|
|
22
|
+
* token lifetime plus the clock-skew leeway, and then disappears. This is what
|
|
23
|
+
* keeps the cache proportional to *recent* revocations rather than to all of
|
|
24
|
+
* them.
|
|
25
|
+
*
|
|
26
|
+
* ## What a cache miss means, and what an outage means
|
|
27
|
+
*
|
|
28
|
+
* A miss means "not revoked" — that is the happy path and it is DB-free, which
|
|
29
|
+
* is the whole design. An *error* means something different and must not be
|
|
30
|
+
* confused with it: a Redis outage that reads as "not revoked" silently
|
|
31
|
+
* un-revokes every session in the system for the duration. So this class throws
|
|
32
|
+
* on failure rather than returning `false`, and {@link AuthGuard} decides —
|
|
33
|
+
* with the database as the authority where one is reachable.
|
|
34
|
+
*
|
|
35
|
+
* There remains one honest gap: if Redis is *up* but has lost the key (an
|
|
36
|
+
* eviction, a flush, a restart without persistence), a revoked session works
|
|
37
|
+
* until its access token expires. Doc 03 §6 accepts exactly that bound —
|
|
38
|
+
* "because access tokens are short-lived, the revocation window is bounded even
|
|
39
|
+
* without a per-request DB hit". The database row stays authoritative, and the
|
|
40
|
+
* next refresh (Session 9) consults it.
|
|
41
|
+
*/
|
|
42
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
43
|
+
exports.RevocationCache = void 0;
|
|
44
|
+
const contracts_1 = require("@plantops/contracts");
|
|
45
|
+
class RevocationCache {
|
|
46
|
+
constructor(store, options) {
|
|
47
|
+
this.store = store;
|
|
48
|
+
this.options = options;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Marks a session dead for every verifier sharing this cache.
|
|
52
|
+
*
|
|
53
|
+
* Call **after** the database write commits. Publishing first would let a
|
|
54
|
+
* concurrent reader see a revoked session that a rollback then restores — the
|
|
55
|
+
* same post-commit ordering the scope-move invalidation requires (Doc 07 §7).
|
|
56
|
+
*/
|
|
57
|
+
async revoke(sessionId) {
|
|
58
|
+
// The value is a marker; only the key's existence carries meaning. `EX`
|
|
59
|
+
// rather than a separate `EXPIRE` so a connection lost between two commands
|
|
60
|
+
// cannot leave an immortal entry behind.
|
|
61
|
+
await this.store.set((0, contracts_1.revokedSessionKey)(sessionId), '1', 'EX', this.options.ttlSeconds);
|
|
62
|
+
}
|
|
63
|
+
/** @throws when the store cannot answer — see the class comment. */
|
|
64
|
+
async isRevoked(sessionId) {
|
|
65
|
+
return (await this.store.exists((0, contracts_1.revokedSessionKey)(sessionId))) > 0;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
exports.RevocationCache = RevocationCache;
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The WHERE dimension, as functions — coverage, query narrowing, and the
|
|
3
|
+
* decision {@link PermissionGuard} enforces (Doc 04 §3–5, §9, Doc 08 §4).
|
|
4
|
+
*
|
|
5
|
+
* ## Why the algorithm lives in a library and not in the IAM
|
|
6
|
+
*
|
|
7
|
+
* Doc 08 §7 singles this out: *"`auth-kit`: unit tests for coverage/scope logic
|
|
8
|
+
* (the highest-risk code — test the `covers()` prefix logic and deny-by-default
|
|
9
|
+
* thoroughly)"*. The reason is that the IAM is not the only process that runs
|
|
10
|
+
* it. `apps/gatepass-api` will answer "may this guard approve at this gate?"
|
|
11
|
+
* from a `ResolvedGrants` it fetched over HTTP, with no database and no IAM code
|
|
12
|
+
* in the request path — and if it re-implements coverage, the two disagree about
|
|
13
|
+
* what lies beneath what, and the artefact of the disagreement is a subject
|
|
14
|
+
* reaching a gate nobody granted them.
|
|
15
|
+
*
|
|
16
|
+
* So the prefix test, the point check and the `allowedPaths` projection are
|
|
17
|
+
* here, as pure functions over the published {@link ResolvedGrants} shape, and
|
|
18
|
+
* everything that needs a database sits behind {@link GrantsSource}.
|
|
19
|
+
*
|
|
20
|
+
* ## Coverage is label-wise, always
|
|
21
|
+
*
|
|
22
|
+
* `covers(N_b, N_t) ⇔ N_t.path <@ N_b.path` (Doc 04 §3). A character-wise
|
|
23
|
+
* `startsWith` would report `n_ab.n_cd` as living under `n_a` — the class of bug
|
|
24
|
+
* that grants access to a sibling subtree — so {@link pathIsWithin} compares on
|
|
25
|
+
* the separator, exactly as ltree's `<@` and migration 0006's GiST index do.
|
|
26
|
+
* `apps/iam-api`'s `scopes/path.util.ts` delegates to this function rather than
|
|
27
|
+
* carrying a second copy.
|
|
28
|
+
*
|
|
29
|
+
* ## The asymmetry of Doc 04 §9 is visible in the code that implements it
|
|
30
|
+
*
|
|
31
|
+
* {@link coversPath} asks two questions in the order the spec asks them: the
|
|
32
|
+
* permission dimension by **exact membership** (holding `dc.approve` says
|
|
33
|
+
* nothing about `dc.create`), and only then the scope dimension, which
|
|
34
|
+
* **inherits** (an ancestor covers its subtree). Merging them into one pass over
|
|
35
|
+
* `scopes` would return the same booleans and would stop the asymmetry being
|
|
36
|
+
* legible — which is how it gets simplified away later.
|
|
37
|
+
*
|
|
38
|
+
* ## Deny-by-default is the absence of a special case
|
|
39
|
+
*
|
|
40
|
+
* A subject with no bindings resolves to `{ permissions: [], scopes: {} }`, and
|
|
41
|
+
* every function below answers `false` or `[]` for it without a branch that says
|
|
42
|
+
* so. That is deliberate: a deny-by-default written as an explicit early return
|
|
43
|
+
* is a deny-by-default somebody can delete.
|
|
44
|
+
*/
|
|
45
|
+
import type { JwtClaims, PermissionKey, ResolvedGrants, ScopePath } from '@plantops/contracts';
|
|
46
|
+
/**
|
|
47
|
+
* The part of a token authorization actually reads: who, of what type, in which
|
|
48
|
+
* tenant, on which session.
|
|
49
|
+
*
|
|
50
|
+
* Narrower than {@link JwtClaims} on purpose. `iss`, `iat` and `exp` are the
|
|
51
|
+
* verifier's business and are finished with by the time this runs, and a port
|
|
52
|
+
* that asked for them would oblige a host to carry them further than it needs
|
|
53
|
+
* to — the IAM in particular hands its RLS layer exactly these four fields
|
|
54
|
+
* (Doc 07 §5) and nothing else.
|
|
55
|
+
*/
|
|
56
|
+
export type SubjectClaims = Pick<JwtClaims, 'sub' | 'sty' | 'cid' | 'sid'>;
|
|
57
|
+
/**
|
|
58
|
+
* `path <@ prefix` — is `path` the prefix itself, or a descendant of it?
|
|
59
|
+
*
|
|
60
|
+
* The one definition of "beneath" in the workspace.
|
|
61
|
+
*/
|
|
62
|
+
export declare function pathIsWithin(path: ScopePath, prefix: ScopePath): boolean;
|
|
63
|
+
/** Does the subject hold `permission` at all — the WHAT, on its own? */
|
|
64
|
+
export declare function holdsPermission(grants: ResolvedGrants, permission: PermissionKey): boolean;
|
|
65
|
+
/**
|
|
66
|
+
* `can(subject, permissionKey, targetPath)` — Doc 04 §4.2's point check.
|
|
67
|
+
*
|
|
68
|
+
* O(covering paths for that permission), which the minimization Doc 04 §4.1
|
|
69
|
+
* requires keeps small.
|
|
70
|
+
*/
|
|
71
|
+
export declare function coversPath(grants: ResolvedGrants, permission: PermissionKey, targetPath: ScopePath): boolean;
|
|
72
|
+
/**
|
|
73
|
+
* The covered subtree roots for one permission — Doc 04 §5's query narrowing.
|
|
74
|
+
*
|
|
75
|
+
* ```sql
|
|
76
|
+
* WHERE visitor.gate_path <@ ANY($1)
|
|
77
|
+
* ```
|
|
78
|
+
*
|
|
79
|
+
* This is the practical payoff of storing scope as a path: a module filters a
|
|
80
|
+
* list endpoint with one predicate instead of asking the IAM per row. The array
|
|
81
|
+
* is already minimal (Doc 04 §4.1), so it is short even for a subject with many
|
|
82
|
+
* bindings, and an empty one is the honest answer for a subject who may see
|
|
83
|
+
* nothing — a module must treat `[]` as "no rows", never as "no filter".
|
|
84
|
+
*/
|
|
85
|
+
export declare function allowedPathsFor(grants: ResolvedGrants, permission: PermissionKey): ScopePath[];
|
|
86
|
+
/** What the resolver concluded about one request. */
|
|
87
|
+
export declare const AuthorizationOutcome: {
|
|
88
|
+
readonly ALLOWED: "allowed";
|
|
89
|
+
/** The subject does not hold the permission anywhere. */
|
|
90
|
+
readonly PERMISSION_DENIED: "permission_denied";
|
|
91
|
+
/** They hold it, but not over the node the request named. */
|
|
92
|
+
readonly SCOPE_DENIED: "scope_denied";
|
|
93
|
+
};
|
|
94
|
+
export type AuthorizationOutcome = (typeof AuthorizationOutcome)[keyof typeof AuthorizationOutcome];
|
|
95
|
+
export interface AuthorizationDecision {
|
|
96
|
+
outcome: AuthorizationOutcome;
|
|
97
|
+
/**
|
|
98
|
+
* What was asked for — carried through so the denial audit can name it.
|
|
99
|
+
*
|
|
100
|
+
* A list because a route may admit any one of several keys
|
|
101
|
+
* (`require-permission.decorator.ts`), which on this surface is Doc 06 §12
|
|
102
|
+
* and nothing else. Which keys are named depends on where the decision was
|
|
103
|
+
* reached, and the difference is what makes the trail readable:
|
|
104
|
+
*
|
|
105
|
+
* - **`permission_denied`** — every key the route would have accepted, since
|
|
106
|
+
* the subject held none of them and all of them are the answer to "what
|
|
107
|
+
* would have let this through".
|
|
108
|
+
* - **`scope_denied`** — only the keys the subject actually *holds*, because
|
|
109
|
+
* those are the ones the coverage test ran against; listing a key they
|
|
110
|
+
* never held would misreport a scope refusal as being about a permission
|
|
111
|
+
* that was never in play.
|
|
112
|
+
* - **`allowed`** — the key that admitted, alone.
|
|
113
|
+
*/
|
|
114
|
+
permissions: readonly PermissionKey[];
|
|
115
|
+
/** The scope node the request named, where it named one. */
|
|
116
|
+
scopeNodeId?: string;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* A subject's grants, and the path of the node a request named.
|
|
120
|
+
*
|
|
121
|
+
* Both come back from one call because they come from one connection: in the
|
|
122
|
+
* IAM the grants may need a resolve against Postgres and the path always does,
|
|
123
|
+
* and opening two connections for one authorization decision is two acquisitions
|
|
124
|
+
* on the hot path (`docs/adr/0001-permission-guard-connection-strategy.md`).
|
|
125
|
+
*/
|
|
126
|
+
export interface AuthorizationSnapshot {
|
|
127
|
+
grants: ResolvedGrants;
|
|
128
|
+
/**
|
|
129
|
+
* Present only when a `scopeNodeId` was asked for. `null` means no such node
|
|
130
|
+
* is visible to this subject — which is deliberately the same answer for a
|
|
131
|
+
* node that does not exist and a node belonging to another tenant, so that a
|
|
132
|
+
* 403 cannot be used as a cross-tenant existence oracle (Doc 06 §2).
|
|
133
|
+
*/
|
|
134
|
+
targetPath?: ScopePath | null;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Where resolved grants come from.
|
|
138
|
+
*
|
|
139
|
+
* The IAM binds the resolution engine of Doc 04 §4 directly; a future module
|
|
140
|
+
* binds a cached `iam-client` call to `/iam/permissions/resolve` (Doc 08 §4).
|
|
141
|
+
* Neither shape is visible from here, which is the whole point of the port —
|
|
142
|
+
* `auth-kit` may depend on `@plantops/contracts` and nothing else (Doc 08 §2),
|
|
143
|
+
* so it cannot name a `DataSource` or an HTTP client even if it wanted to.
|
|
144
|
+
*/
|
|
145
|
+
export interface GrantsSource {
|
|
146
|
+
/**
|
|
147
|
+
* @param claims the verified claims of the request being authorized.
|
|
148
|
+
* @param scopeNodeId when given, also resolve this node's path under the
|
|
149
|
+
* subject's own tenant.
|
|
150
|
+
*/
|
|
151
|
+
authorize(claims: SubjectClaims, scopeNodeId?: string): Promise<AuthorizationSnapshot>;
|
|
152
|
+
}
|
|
153
|
+
export declare const GRANTS_SOURCE: unique symbol;
|
|
154
|
+
/**
|
|
155
|
+
* The authorization decisions Doc 04 §8 orders, answerable without any
|
|
156
|
+
* framework: construct it with anything that satisfies {@link GrantsSource}
|
|
157
|
+
* — the IAM\u2019s Nest binding, an HTTP call to `/iam/permissions/resolve`, a
|
|
158
|
+
* fixture in a test — and every method behaves identically.
|
|
159
|
+
*
|
|
160
|
+
* The NestJS adapter (`adapters/nestjs`) subclasses this only to receive its
|
|
161
|
+
* dependency through Nest\u2019s injector; the rule itself lives here, where it
|
|
162
|
+
* can be exercised by a plain Node script.
|
|
163
|
+
*/
|
|
164
|
+
export declare class ScopeResolverCore {
|
|
165
|
+
private readonly source;
|
|
166
|
+
constructor(source: GrantsSource);
|
|
167
|
+
/** The subject\u2019s complete grant set (Doc 04 §4.1). */
|
|
168
|
+
grantsFor(claims: SubjectClaims): Promise<ResolvedGrants>;
|
|
169
|
+
/** Doc 04 §5\u2019s `allowedPaths`, for query narrowing. */
|
|
170
|
+
allowedPaths(claims: SubjectClaims, permission: PermissionKey): Promise<ScopePath[]>;
|
|
171
|
+
/** Doc 04 §4.2\u2019s point check against a node named by id. */
|
|
172
|
+
covers(claims: SubjectClaims, permission: PermissionKey, scopeNodeId: string): Promise<boolean>;
|
|
173
|
+
/**
|
|
174
|
+
* Hold the permission first, then cover the node — Doc 04 §8\u2019s order, which
|
|
175
|
+
* decides which denial the caller gets: PERMISSION_DENIED for a subject who
|
|
176
|
+
* holds nothing, SCOPE_DENIED for one who holds it elsewhere.
|
|
177
|
+
*
|
|
178
|
+
* A node invisible under this subject\u2019s own RLS context reports
|
|
179
|
+
* `targetPath: null`, and the decision is ALLOWED: invisibility is the
|
|
180
|
+
* handler\u2019s 404/409 to give, not a 403 that turns every scoped route into
|
|
181
|
+
* a cross-tenant existence oracle. What the guard refuses is exactly what
|
|
182
|
+
* only it can see — a reachable node that is not covered.
|
|
183
|
+
*/
|
|
184
|
+
decide(claims: SubjectClaims, requirement: PermissionRequirement, scopeNodeId?: string): Promise<AuthorizationDecision>;
|
|
185
|
+
}
|
|
186
|
+
/** Where {@link PermissionGuard} reads a route\u2019s requirement from. */
|
|
187
|
+
export declare const REQUIRE_PERMISSION_METADATA = "auth-kit:require-permission";
|
|
188
|
+
/** Where it reads a route\u2019s deliberate opt-out from. */
|
|
189
|
+
export declare const NO_PERMISSION_METADATA = "auth-kit:no-permission";
|
|
190
|
+
export interface RequirePermissionOptions {
|
|
191
|
+
/**
|
|
192
|
+
* Dotted path to the target **scope node id** in the request, e.g.
|
|
193
|
+
* `params.id`, `body.scope_node_id`. Omitted where the route names no node.
|
|
194
|
+
*/
|
|
195
|
+
scopeFrom?: string;
|
|
196
|
+
/**
|
|
197
|
+
* Treat an absent value at `scopeFrom` as "no node named" rather than as a
|
|
198
|
+
* denial.
|
|
199
|
+
*
|
|
200
|
+
* Off by default, and the default is the safe one: a route that says it takes
|
|
201
|
+
* a target and then receives none has nothing to run the coverage test
|
|
202
|
+
* against, and letting it through would silently downgrade a scoped check to
|
|
203
|
+
* an unscoped one. Turn it on only where absence is a legitimate shape of the
|
|
204
|
+
* request — `POST /iam/scopes` creating a tenant\u2019s first root has no parent
|
|
205
|
+
* to be covered by, and there is no other node it could name.
|
|
206
|
+
*/
|
|
207
|
+
scopeOptional?: boolean;
|
|
208
|
+
}
|
|
209
|
+
/** What a route declares about itself. */
|
|
210
|
+
export interface PermissionRequirement extends RequirePermissionOptions {
|
|
211
|
+
/**
|
|
212
|
+
* The keys that admit, any one of which is enough. One on all but a single
|
|
213
|
+
* route — the decorator\u2019s header has the full argument.
|
|
214
|
+
*
|
|
215
|
+
* Normalized to an array by the decorator so that everything downstream —
|
|
216
|
+
* `ScopeResolver.decide`, the guard, the denial audit — has one shape to
|
|
217
|
+
* handle rather than a union it must narrow at each step.
|
|
218
|
+
*/
|
|
219
|
+
permissions: readonly PermissionKey[];
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Reads `path` out of `request`, one segment at a time.
|
|
223
|
+
*
|
|
224
|
+
* Traversal stops at anything that is not a plain object, and the well-known
|
|
225
|
+
* prototype keys are refused outright: `scopeFrom` is authored in a controller
|
|
226
|
+
* rather than sent by a caller, but a decorator string is still a string, and a
|
|
227
|
+
* lookup that could walk into `__proto__` is one that would resolve to
|
|
228
|
+
* something no request contains.
|
|
229
|
+
*
|
|
230
|
+
* Returns `undefined` for anything that is not a non-empty string, so a missing
|
|
231
|
+
* parameter and a `null` body field are the same "no node named" — the caller
|
|
232
|
+
* chose neither.
|
|
233
|
+
*/
|
|
234
|
+
export declare function readScopeTarget(request: unknown, path: string): string | undefined;
|
|
235
|
+
//# sourceMappingURL=scope-resolver.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scope-resolver.d.ts","sourceRoot":"","sources":["../../src/core/scope-resolver.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The WHERE dimension, as functions — coverage, query narrowing, and the
|
|
4
|
+
* decision {@link PermissionGuard} enforces (Doc 04 §3–5, §9, Doc 08 §4).
|
|
5
|
+
*
|
|
6
|
+
* ## Why the algorithm lives in a library and not in the IAM
|
|
7
|
+
*
|
|
8
|
+
* Doc 08 §7 singles this out: *"`auth-kit`: unit tests for coverage/scope logic
|
|
9
|
+
* (the highest-risk code — test the `covers()` prefix logic and deny-by-default
|
|
10
|
+
* thoroughly)"*. The reason is that the IAM is not the only process that runs
|
|
11
|
+
* it. `apps/gatepass-api` will answer "may this guard approve at this gate?"
|
|
12
|
+
* from a `ResolvedGrants` it fetched over HTTP, with no database and no IAM code
|
|
13
|
+
* in the request path — and if it re-implements coverage, the two disagree about
|
|
14
|
+
* what lies beneath what, and the artefact of the disagreement is a subject
|
|
15
|
+
* reaching a gate nobody granted them.
|
|
16
|
+
*
|
|
17
|
+
* So the prefix test, the point check and the `allowedPaths` projection are
|
|
18
|
+
* here, as pure functions over the published {@link ResolvedGrants} shape, and
|
|
19
|
+
* everything that needs a database sits behind {@link GrantsSource}.
|
|
20
|
+
*
|
|
21
|
+
* ## Coverage is label-wise, always
|
|
22
|
+
*
|
|
23
|
+
* `covers(N_b, N_t) ⇔ N_t.path <@ N_b.path` (Doc 04 §3). A character-wise
|
|
24
|
+
* `startsWith` would report `n_ab.n_cd` as living under `n_a` — the class of bug
|
|
25
|
+
* that grants access to a sibling subtree — so {@link pathIsWithin} compares on
|
|
26
|
+
* the separator, exactly as ltree's `<@` and migration 0006's GiST index do.
|
|
27
|
+
* `apps/iam-api`'s `scopes/path.util.ts` delegates to this function rather than
|
|
28
|
+
* carrying a second copy.
|
|
29
|
+
*
|
|
30
|
+
* ## The asymmetry of Doc 04 §9 is visible in the code that implements it
|
|
31
|
+
*
|
|
32
|
+
* {@link coversPath} asks two questions in the order the spec asks them: the
|
|
33
|
+
* permission dimension by **exact membership** (holding `dc.approve` says
|
|
34
|
+
* nothing about `dc.create`), and only then the scope dimension, which
|
|
35
|
+
* **inherits** (an ancestor covers its subtree). Merging them into one pass over
|
|
36
|
+
* `scopes` would return the same booleans and would stop the asymmetry being
|
|
37
|
+
* legible — which is how it gets simplified away later.
|
|
38
|
+
*
|
|
39
|
+
* ## Deny-by-default is the absence of a special case
|
|
40
|
+
*
|
|
41
|
+
* A subject with no bindings resolves to `{ permissions: [], scopes: {} }`, and
|
|
42
|
+
* every function below answers `false` or `[]` for it without a branch that says
|
|
43
|
+
* so. That is deliberate: a deny-by-default written as an explicit early return
|
|
44
|
+
* is a deny-by-default somebody can delete.
|
|
45
|
+
*/
|
|
46
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
47
|
+
exports.NO_PERMISSION_METADATA = exports.REQUIRE_PERMISSION_METADATA = exports.ScopeResolverCore = exports.GRANTS_SOURCE = exports.AuthorizationOutcome = void 0;
|
|
48
|
+
exports.pathIsWithin = pathIsWithin;
|
|
49
|
+
exports.holdsPermission = holdsPermission;
|
|
50
|
+
exports.coversPath = coversPath;
|
|
51
|
+
exports.allowedPathsFor = allowedPathsFor;
|
|
52
|
+
exports.readScopeTarget = readScopeTarget;
|
|
53
|
+
/** The ltree label separator. Spelled once. */
|
|
54
|
+
const SEPARATOR = '.';
|
|
55
|
+
/**
|
|
56
|
+
* `path <@ prefix` — is `path` the prefix itself, or a descendant of it?
|
|
57
|
+
*
|
|
58
|
+
* The one definition of "beneath" in the workspace.
|
|
59
|
+
*/
|
|
60
|
+
function pathIsWithin(path, prefix) {
|
|
61
|
+
return path === prefix || path.startsWith(`${prefix}${SEPARATOR}`);
|
|
62
|
+
}
|
|
63
|
+
/** Does the subject hold `permission` at all — the WHAT, on its own? */
|
|
64
|
+
function holdsPermission(grants, permission) {
|
|
65
|
+
return grants.permissions.includes(permission);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* `can(subject, permissionKey, targetPath)` — Doc 04 §4.2's point check.
|
|
69
|
+
*
|
|
70
|
+
* O(covering paths for that permission), which the minimization Doc 04 §4.1
|
|
71
|
+
* requires keeps small.
|
|
72
|
+
*/
|
|
73
|
+
function coversPath(grants, permission, targetPath) {
|
|
74
|
+
if (!holdsPermission(grants, permission))
|
|
75
|
+
return false;
|
|
76
|
+
const covering = grants.scopes[permission];
|
|
77
|
+
return covering !== undefined && covering.some((path) => pathIsWithin(targetPath, path));
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The covered subtree roots for one permission — Doc 04 §5's query narrowing.
|
|
81
|
+
*
|
|
82
|
+
* ```sql
|
|
83
|
+
* WHERE visitor.gate_path <@ ANY($1)
|
|
84
|
+
* ```
|
|
85
|
+
*
|
|
86
|
+
* This is the practical payoff of storing scope as a path: a module filters a
|
|
87
|
+
* list endpoint with one predicate instead of asking the IAM per row. The array
|
|
88
|
+
* is already minimal (Doc 04 §4.1), so it is short even for a subject with many
|
|
89
|
+
* bindings, and an empty one is the honest answer for a subject who may see
|
|
90
|
+
* nothing — a module must treat `[]` as "no rows", never as "no filter".
|
|
91
|
+
*/
|
|
92
|
+
function allowedPathsFor(grants, permission) {
|
|
93
|
+
return grants.scopes[permission] ?? [];
|
|
94
|
+
}
|
|
95
|
+
/** What the resolver concluded about one request. */
|
|
96
|
+
exports.AuthorizationOutcome = {
|
|
97
|
+
ALLOWED: 'allowed',
|
|
98
|
+
/** The subject does not hold the permission anywhere. */
|
|
99
|
+
PERMISSION_DENIED: 'permission_denied',
|
|
100
|
+
/** They hold it, but not over the node the request named. */
|
|
101
|
+
SCOPE_DENIED: 'scope_denied',
|
|
102
|
+
};
|
|
103
|
+
exports.GRANTS_SOURCE = Symbol('auth-kit:GrantsSource');
|
|
104
|
+
/**
|
|
105
|
+
* The authorization decisions Doc 04 §8 orders, answerable without any
|
|
106
|
+
* framework: construct it with anything that satisfies {@link GrantsSource}
|
|
107
|
+
* — the IAM\u2019s Nest binding, an HTTP call to `/iam/permissions/resolve`, a
|
|
108
|
+
* fixture in a test — and every method behaves identically.
|
|
109
|
+
*
|
|
110
|
+
* The NestJS adapter (`adapters/nestjs`) subclasses this only to receive its
|
|
111
|
+
* dependency through Nest\u2019s injector; the rule itself lives here, where it
|
|
112
|
+
* can be exercised by a plain Node script.
|
|
113
|
+
*/
|
|
114
|
+
class ScopeResolverCore {
|
|
115
|
+
constructor(source) {
|
|
116
|
+
this.source = source;
|
|
117
|
+
}
|
|
118
|
+
/** The subject\u2019s complete grant set (Doc 04 §4.1). */
|
|
119
|
+
async grantsFor(claims) {
|
|
120
|
+
const { grants } = await this.source.authorize(claims);
|
|
121
|
+
return grants;
|
|
122
|
+
}
|
|
123
|
+
/** Doc 04 §5\u2019s `allowedPaths`, for query narrowing. */
|
|
124
|
+
async allowedPaths(claims, permission) {
|
|
125
|
+
return allowedPathsFor(await this.grantsFor(claims), permission);
|
|
126
|
+
}
|
|
127
|
+
/** Doc 04 §4.2\u2019s point check against a node named by id. */
|
|
128
|
+
async covers(claims, permission, scopeNodeId) {
|
|
129
|
+
const snapshot = await this.source.authorize(claims, scopeNodeId);
|
|
130
|
+
const targetPath = snapshot.targetPath ?? null;
|
|
131
|
+
return targetPath !== null && coversPath(snapshot.grants, permission, targetPath);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Hold the permission first, then cover the node — Doc 04 §8\u2019s order, which
|
|
135
|
+
* decides which denial the caller gets: PERMISSION_DENIED for a subject who
|
|
136
|
+
* holds nothing, SCOPE_DENIED for one who holds it elsewhere.
|
|
137
|
+
*
|
|
138
|
+
* A node invisible under this subject\u2019s own RLS context reports
|
|
139
|
+
* `targetPath: null`, and the decision is ALLOWED: invisibility is the
|
|
140
|
+
* handler\u2019s 404/409 to give, not a 403 that turns every scoped route into
|
|
141
|
+
* a cross-tenant existence oracle. What the guard refuses is exactly what
|
|
142
|
+
* only it can see — a reachable node that is not covered.
|
|
143
|
+
*/
|
|
144
|
+
async decide(claims, requirement, scopeNodeId) {
|
|
145
|
+
const scoped = requirement.scopeFrom !== undefined && scopeNodeId !== undefined;
|
|
146
|
+
const snapshot = await this.source.authorize(claims, scoped ? scopeNodeId : undefined);
|
|
147
|
+
const at = (scoped ? { scopeNodeId } : {});
|
|
148
|
+
// Held first, covered second — Doc 04 §9\u2019s asymmetry, over however many
|
|
149
|
+
// keys the route admits.
|
|
150
|
+
const held = requirement.permissions.filter((permission) => holdsPermission(snapshot.grants, permission));
|
|
151
|
+
if (held.length === 0) {
|
|
152
|
+
return {
|
|
153
|
+
...at,
|
|
154
|
+
permissions: requirement.permissions,
|
|
155
|
+
outcome: exports.AuthorizationOutcome.PERMISSION_DENIED,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
if (requirement.scopeFrom !== undefined && scopeNodeId === undefined) {
|
|
159
|
+
return requirement.scopeOptional
|
|
160
|
+
? { ...at, permissions: [held[0]], outcome: exports.AuthorizationOutcome.ALLOWED }
|
|
161
|
+
: { ...at, permissions: held, outcome: exports.AuthorizationOutcome.SCOPE_DENIED };
|
|
162
|
+
}
|
|
163
|
+
if (!scoped) {
|
|
164
|
+
return { ...at, permissions: [held[0]], outcome: exports.AuthorizationOutcome.ALLOWED };
|
|
165
|
+
}
|
|
166
|
+
const targetPath = snapshot.targetPath ?? null;
|
|
167
|
+
if (targetPath === null) {
|
|
168
|
+
return { ...at, permissions: [held[0]], outcome: exports.AuthorizationOutcome.ALLOWED };
|
|
169
|
+
}
|
|
170
|
+
const covering = held.find((permission) => coversPath(snapshot.grants, permission, targetPath));
|
|
171
|
+
return covering === undefined
|
|
172
|
+
? { ...at, permissions: held, outcome: exports.AuthorizationOutcome.SCOPE_DENIED }
|
|
173
|
+
: { ...at, permissions: [covering], outcome: exports.AuthorizationOutcome.ALLOWED };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
exports.ScopeResolverCore = ScopeResolverCore;
|
|
177
|
+
/** Where {@link PermissionGuard} reads a route\u2019s requirement from. */
|
|
178
|
+
exports.REQUIRE_PERMISSION_METADATA = 'auth-kit:require-permission';
|
|
179
|
+
/** Where it reads a route\u2019s deliberate opt-out from. */
|
|
180
|
+
exports.NO_PERMISSION_METADATA = 'auth-kit:no-permission';
|
|
181
|
+
/**
|
|
182
|
+
* Reads `path` out of `request`, one segment at a time.
|
|
183
|
+
*
|
|
184
|
+
* Traversal stops at anything that is not a plain object, and the well-known
|
|
185
|
+
* prototype keys are refused outright: `scopeFrom` is authored in a controller
|
|
186
|
+
* rather than sent by a caller, but a decorator string is still a string, and a
|
|
187
|
+
* lookup that could walk into `__proto__` is one that would resolve to
|
|
188
|
+
* something no request contains.
|
|
189
|
+
*
|
|
190
|
+
* Returns `undefined` for anything that is not a non-empty string, so a missing
|
|
191
|
+
* parameter and a `null` body field are the same "no node named" — the caller
|
|
192
|
+
* chose neither.
|
|
193
|
+
*/
|
|
194
|
+
function readScopeTarget(request, path) {
|
|
195
|
+
const segments = path.split('.');
|
|
196
|
+
let current = request;
|
|
197
|
+
for (const segment of segments) {
|
|
198
|
+
if (segment === '__proto__' || segment === 'constructor' || segment === 'prototype') {
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
if (typeof current !== 'object' || current === null)
|
|
202
|
+
return undefined;
|
|
203
|
+
current = current[segment];
|
|
204
|
+
}
|
|
205
|
+
return typeof current === 'string' && current.trim() !== '' ? current : undefined;
|
|
206
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@plantops/auth-kit` — authorization for everything that talks to the IAM
|
|
3
|
+
* (Doc 08 §4), in three layers after roadmap Session 50:
|
|
4
|
+
*
|
|
5
|
+
* - **`core/**`** — framework-free. Token verification, the closed claim set,
|
|
6
|
+
* revocation caching and the pure scope-coverage vocabulary. Runs in Nest,
|
|
7
|
+
* in a plain Node script, in a Next.js route handler — anywhere.
|
|
8
|
+
* - **`adapters/nestjs`** — the guards, decorators and injectable resolver,
|
|
9
|
+
* byte-for-byte the surface the IAM has always had.
|
|
10
|
+
* - **`adapters/fetch`** — a `Request`-in, decision-out helper for Next.js
|
|
11
|
+
* route handlers and any WinterCG runtime.
|
|
12
|
+
*/
|
|
13
|
+
export * from './core';
|
|
14
|
+
export * from './adapters/nestjs';
|
|
15
|
+
export * from './adapters/fetch';
|
|
16
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,cAAc,QAAQ,CAAC;AACvB,cAAc,mBAAmB,CAAC;AAClC,cAAc,kBAAkB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `@plantops/auth-kit` — authorization for everything that talks to the IAM
|
|
4
|
+
* (Doc 08 §4), in three layers after roadmap Session 50:
|
|
5
|
+
*
|
|
6
|
+
* - **`core/**`** — framework-free. Token verification, the closed claim set,
|
|
7
|
+
* revocation caching and the pure scope-coverage vocabulary. Runs in Nest,
|
|
8
|
+
* in a plain Node script, in a Next.js route handler — anywhere.
|
|
9
|
+
* - **`adapters/nestjs`** — the guards, decorators and injectable resolver,
|
|
10
|
+
* byte-for-byte the surface the IAM has always had.
|
|
11
|
+
* - **`adapters/fetch`** — a `Request`-in, decision-out helper for Next.js
|
|
12
|
+
* route handlers and any WinterCG runtime.
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
const tslib_1 = require("tslib");
|
|
16
|
+
tslib_1.__exportStar(require("./core"), exports);
|
|
17
|
+
tslib_1.__exportStar(require("./adapters/nestjs"), exports);
|
|
18
|
+
tslib_1.__exportStar(require("./adapters/fetch"), exports);
|