@propustka/client 0.0.1
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/package.json +34 -0
- package/src/client.ts +202 -0
- package/src/fake.ts +264 -0
- package/src/index.ts +25 -0
- package/src/request.ts +53 -0
- package/src/scope.ts +24 -0
- package/src/types.ts +130 -0
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@propustka/client",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/contember/propustka.git",
|
|
8
|
+
"directory": "packages/client"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"!src/**/__tests__/**"
|
|
16
|
+
],
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"bun": "./src/index.ts",
|
|
20
|
+
"types": "./src/index.ts",
|
|
21
|
+
"default": "./src/index.ts"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"typecheck": "tsc --noEmit",
|
|
26
|
+
"test": "bun test"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@propustka/core": "0.0.1"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@cloudflare/workers-types": "^4.20251001.0"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import type { DomainEvent, IamRpc, ResolvedPrincipal } from '@propustka/core'
|
|
2
|
+
import { permits, scopedProjects } from '@propustka/core'
|
|
3
|
+
import { readCredentials } from './request'
|
|
4
|
+
import type {
|
|
5
|
+
AuthContext,
|
|
6
|
+
AuthFailure,
|
|
7
|
+
Capability,
|
|
8
|
+
CapabilityFailure,
|
|
9
|
+
IssueCapabilityRequest,
|
|
10
|
+
IssuedCapability,
|
|
11
|
+
IssueFailure,
|
|
12
|
+
PrincipalIdentity,
|
|
13
|
+
RevokedCapability,
|
|
14
|
+
RevokeFailure,
|
|
15
|
+
} from './types'
|
|
16
|
+
|
|
17
|
+
// ── AuthContext (real) ─────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The real, principal-backed `AuthContext`. `can`/`scopedTo` are pure functions over the
|
|
21
|
+
* permissions array the IAM Worker already resolved (no per-check round-trip); only `audit`
|
|
22
|
+
* calls the binding.
|
|
23
|
+
*/
|
|
24
|
+
class RealAuthContext implements AuthContext {
|
|
25
|
+
readonly ok = true
|
|
26
|
+
readonly principal: PrincipalIdentity
|
|
27
|
+
|
|
28
|
+
constructor(
|
|
29
|
+
private readonly binding: IamRpc,
|
|
30
|
+
private readonly appId: string,
|
|
31
|
+
private readonly resolved: ResolvedPrincipal,
|
|
32
|
+
) {
|
|
33
|
+
this.principal = { id: resolved.id, type: resolved.type, label: resolved.label }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
can(action: string, scope?: { project?: string }): boolean {
|
|
37
|
+
// `permits` already encodes the scope-less rule: with no project, ONLY global
|
|
38
|
+
// (projectId === null) entries satisfy.
|
|
39
|
+
return permits(this.resolved.permissions, action, scope?.project)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
scopedTo(action: string, _dimension = 'project'): string[] | null {
|
|
43
|
+
// `dimension` is forward-looking only — v1 has a single scope dimension (project).
|
|
44
|
+
return scopedProjects(this.resolved.permissions, action)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
audit(event: DomainEvent): Promise<void> {
|
|
48
|
+
return this.binding.audit({
|
|
49
|
+
app: this.appId,
|
|
50
|
+
requestId: this.resolved.requestId,
|
|
51
|
+
principalId: this.resolved.id,
|
|
52
|
+
principalLabel: this.resolved.label,
|
|
53
|
+
action: event.action,
|
|
54
|
+
resourceType: event.resourceType,
|
|
55
|
+
resourceId: event.resourceId,
|
|
56
|
+
diff: event.diff,
|
|
57
|
+
metadata: event.metadata,
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Capability (real) ──────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A redeemed, anonymous capability token. `can` is EXACT-match (action, resource) — no
|
|
66
|
+
* wildcards. `audit` attaches the token id + label and a null principal.
|
|
67
|
+
*/
|
|
68
|
+
class RealCapability implements Capability {
|
|
69
|
+
readonly ok = true
|
|
70
|
+
|
|
71
|
+
constructor(
|
|
72
|
+
private readonly binding: IamRpc,
|
|
73
|
+
private readonly appId: string,
|
|
74
|
+
private readonly requestId: string,
|
|
75
|
+
private readonly tokenId: string,
|
|
76
|
+
private readonly label: string | null,
|
|
77
|
+
private readonly capabilities: ReadonlyArray<{ action: string; resource: string }>,
|
|
78
|
+
) {}
|
|
79
|
+
|
|
80
|
+
can(action: string, resource: string): boolean {
|
|
81
|
+
for (const c of this.capabilities) {
|
|
82
|
+
if (c.action === action && c.resource === resource) {
|
|
83
|
+
return true
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return false
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
audit(event: DomainEvent): Promise<void> {
|
|
90
|
+
return this.binding.audit({
|
|
91
|
+
app: this.appId,
|
|
92
|
+
requestId: this.requestId,
|
|
93
|
+
principalId: null,
|
|
94
|
+
// Snapshot the token label; fall back to `capability:<id>` when unlabeled.
|
|
95
|
+
principalLabel: this.label ?? `capability:${this.tokenId}`,
|
|
96
|
+
capabilityTokenId: this.tokenId,
|
|
97
|
+
action: event.action,
|
|
98
|
+
resourceType: event.resourceType,
|
|
99
|
+
resourceId: event.resourceId,
|
|
100
|
+
diff: event.diff,
|
|
101
|
+
metadata: event.metadata,
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── IamClient ──────────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Thin, app-facing wrapper over the IAM Worker service binding. Bakes the caller `app` id
|
|
110
|
+
* into the constructor so app code can never forget or mistype it. Depends ONLY on the
|
|
111
|
+
* `IamRpc` contract from `@propustka/core` — never on the Worker — which is what keeps the
|
|
112
|
+
* SDK worker-independent (the binding is the deployed Worker, reached at runtime).
|
|
113
|
+
*/
|
|
114
|
+
export class IamClient {
|
|
115
|
+
constructor(
|
|
116
|
+
private readonly binding: IamRpc,
|
|
117
|
+
private readonly appId: string,
|
|
118
|
+
) {}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolve the caller from the forwarded Access credentials. Returns a rich `AuthContext`
|
|
122
|
+
* on success, or a typed `AuthFailure` carrying the 401/403 status (missing/invalid → 401;
|
|
123
|
+
* unknown_principal/disabled → 403).
|
|
124
|
+
*/
|
|
125
|
+
async authenticate(req: Request): Promise<AuthContext | AuthFailure> {
|
|
126
|
+
const { token, cookie, origin, requestId } = readCredentials(req)
|
|
127
|
+
const result = await this.binding.authenticate({ app: this.appId, token, cookie, origin, requestId })
|
|
128
|
+
if (result.ok) {
|
|
129
|
+
return new RealAuthContext(this.binding, this.appId, result.principal)
|
|
130
|
+
}
|
|
131
|
+
return { ok: false, reason: result.reason, status: authFailureStatus(result.reason) }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Redeem a capability token (a share link). No identity. A bad/expired/revoked/exhausted
|
|
136
|
+
* token reads as a 404 (the link is "invalid or expired"), never a leaky 401/403.
|
|
137
|
+
*/
|
|
138
|
+
async redeemCapability(req: Request, token: string): Promise<Capability | CapabilityFailure> {
|
|
139
|
+
const { requestId } = readCredentials(req)
|
|
140
|
+
const result = await this.binding.redeemCapability({ app: this.appId, token, requestId })
|
|
141
|
+
if (result.ok) {
|
|
142
|
+
return new RealCapability(this.binding, this.appId, requestId, result.tokenId, result.label, result.capabilities)
|
|
143
|
+
}
|
|
144
|
+
return { ok: false, reason: result.reason, status: 404 }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Mint a capability (share link) in-flow. Forwards the REQUESTER's credentials as the
|
|
149
|
+
* issuer — the IAM Worker resolves the issuer server-side and enforces the delegation rule
|
|
150
|
+
* (you can only delegate what you can do). The app supplies only the grants/label/expiry.
|
|
151
|
+
*/
|
|
152
|
+
async issueCapability(req: Request, input: IssueCapabilityRequest): Promise<IssuedCapability | IssueFailure> {
|
|
153
|
+
const { token, cookie, origin, requestId } = readCredentials(req)
|
|
154
|
+
const result = await this.binding.issueCapability({
|
|
155
|
+
app: this.appId,
|
|
156
|
+
token,
|
|
157
|
+
cookie,
|
|
158
|
+
origin,
|
|
159
|
+
requestId,
|
|
160
|
+
grants: input.grants,
|
|
161
|
+
label: input.label,
|
|
162
|
+
expiresAt: input.expiresAt,
|
|
163
|
+
maxUses: input.maxUses,
|
|
164
|
+
})
|
|
165
|
+
if (result.ok) {
|
|
166
|
+
return { ok: true, token: result.token, id: result.id }
|
|
167
|
+
}
|
|
168
|
+
return { ok: false, reason: result.reason, status: issueFailureStatus(result.reason) }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Revoke a capability (share link) by its id. Forwards the CALLER's credentials as the
|
|
173
|
+
* authorizer — the IAM Worker resolves the caller server-side and enforces the rule (the
|
|
174
|
+
* original issuer, or anyone who could re-issue the grants, may revoke). Idempotent: a
|
|
175
|
+
* second revoke returns `{ ok: true, revoked: false }`. An unknown id → 404.
|
|
176
|
+
*/
|
|
177
|
+
async revokeCapability(req: Request, tokenId: string): Promise<RevokedCapability | RevokeFailure> {
|
|
178
|
+
const { token, cookie, origin, requestId } = readCredentials(req)
|
|
179
|
+
const result = await this.binding.revokeCapability({ app: this.appId, token, cookie, origin, requestId, tokenId })
|
|
180
|
+
if (result.ok) {
|
|
181
|
+
return { ok: true, revoked: result.revoked }
|
|
182
|
+
}
|
|
183
|
+
return { ok: false, reason: result.reason, status: revokeFailureStatus(result.reason) }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** missing/invalid token → 401 (not authenticated); unknown/disabled principal → 403. */
|
|
188
|
+
function authFailureStatus(reason: AuthFailure['reason']): 401 | 403 {
|
|
189
|
+
return reason === 'missing_token' || reason === 'invalid_token' ? 401 : 403
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** missing/invalid → 401; unknown_principal/disabled/not_allowed → 403. */
|
|
193
|
+
function issueFailureStatus(reason: IssueFailure['reason']): 401 | 403 {
|
|
194
|
+
return reason === 'missing_token' || reason === 'invalid_token' ? 401 : 403
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** missing/invalid → 401; not_found → 404; unknown_principal/disabled/not_allowed → 403. */
|
|
198
|
+
function revokeFailureStatus(reason: RevokeFailure['reason']): 401 | 403 | 404 {
|
|
199
|
+
if (reason === 'missing_token' || reason === 'invalid_token') return 401
|
|
200
|
+
if (reason === 'not_found') return 404
|
|
201
|
+
return 403
|
|
202
|
+
}
|
package/src/fake.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import type { DomainEvent, PermissionEntry, PrincipalType } from '@propustka/core'
|
|
2
|
+
import { matchAction, permits, scopedProjects } from '@propustka/core'
|
|
3
|
+
import type {
|
|
4
|
+
AuthContext,
|
|
5
|
+
AuthFailure,
|
|
6
|
+
Capability,
|
|
7
|
+
CapabilityFailure,
|
|
8
|
+
IssueCapabilityRequest,
|
|
9
|
+
IssuedCapability,
|
|
10
|
+
IssueFailure,
|
|
11
|
+
PrincipalIdentity,
|
|
12
|
+
RevokedCapability,
|
|
13
|
+
RevokeFailure,
|
|
14
|
+
} from './types'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A fixed dev persona: an identity plus a real permissions array. When `FakeIamConfig.personas`
|
|
18
|
+
* is set, the fake resolves one of these per request (by a cookie / header key) and backs
|
|
19
|
+
* `can()` / `scopedTo()` with the SAME `permits` / `scopedProjects` core logic the real client
|
|
20
|
+
* uses — so role/scope behaviour (admin vs app-wide vs project-scoped) is exercisable in dev and
|
|
21
|
+
* browser tests without Cloudflare Access or a running IAM Worker.
|
|
22
|
+
*/
|
|
23
|
+
export interface FakePersona {
|
|
24
|
+
id: string
|
|
25
|
+
label: string
|
|
26
|
+
type?: PrincipalType
|
|
27
|
+
/** The resolved permission entries — real `permits`/`scopedProjects` semantics (not allow-all). */
|
|
28
|
+
permissions: PermissionEntry[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Config for the fake. Two modes:
|
|
33
|
+
*
|
|
34
|
+
* - SIMPLE (default): a single fixed identity, `can()` allows everything except the `deny`
|
|
35
|
+
* action patterns (same `*`/`prefix.*` matching as roles) so 403 paths are still testable.
|
|
36
|
+
* `principal` overrides the fixed identity (id/label/type).
|
|
37
|
+
*
|
|
38
|
+
* - PERSONA: set `personas` (keyed by an opaque selector, e.g. an email) to make the fake
|
|
39
|
+
* impersonate a specific principal per request, with real permission semantics. The active
|
|
40
|
+
* persona key is read from the `personaCookie` (default `propustka_dev_principal`) or the
|
|
41
|
+
* `personaHeader` (default `X-Dev-Principal`), falling back to `defaultPersona`. A cookie is
|
|
42
|
+
* what browser E2E uses (it rides every navigation + fetch); the header suits CLI/fetch. An
|
|
43
|
+
* unknown/absent key with no default resolves to `unknown_principal` (403) — the same shape a
|
|
44
|
+
* real unrecognised principal gets.
|
|
45
|
+
*/
|
|
46
|
+
export interface FakeIamConfig {
|
|
47
|
+
deny?: string[]
|
|
48
|
+
principal?: {
|
|
49
|
+
id?: string
|
|
50
|
+
label?: string
|
|
51
|
+
type?: PrincipalType
|
|
52
|
+
}
|
|
53
|
+
personas?: Record<string, FakePersona>
|
|
54
|
+
/** Cookie carrying the active persona key (browser E2E). Default `propustka_dev_principal`. */
|
|
55
|
+
personaCookie?: string
|
|
56
|
+
/** Header carrying the active persona key (fetch/CLI). Default `X-Dev-Principal`. */
|
|
57
|
+
personaHeader?: string
|
|
58
|
+
/** Persona key used when neither cookie nor header is present. */
|
|
59
|
+
defaultPersona?: string
|
|
60
|
+
/**
|
|
61
|
+
* Dynamic per-request persona resolver — the most flexible mode (takes precedence over
|
|
62
|
+
* `personas`/`deny`). The app derives the persona however it likes (e.g. a dev cookie →
|
|
63
|
+
* a directory lookup), so personas need not be enumerated up front. Returning `null`
|
|
64
|
+
* resolves to `unknown_principal` (403), like a real unrecognised principal.
|
|
65
|
+
*/
|
|
66
|
+
resolve?: (req: Request) => FakePersona | null | Promise<FakePersona | null>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface FakeIdentity {
|
|
70
|
+
id: string
|
|
71
|
+
label: string
|
|
72
|
+
type: PrincipalType
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const DEFAULT_PERSONA_COOKIE = 'propustka_dev_principal'
|
|
76
|
+
const DEFAULT_PERSONA_HEADER = 'X-Dev-Principal'
|
|
77
|
+
|
|
78
|
+
function resolveIdentity(config: FakeIamConfig | undefined): FakeIdentity {
|
|
79
|
+
return {
|
|
80
|
+
id: config?.principal?.id ?? 'fake-principal',
|
|
81
|
+
label: config?.principal?.label ?? 'fake@example.com',
|
|
82
|
+
type: config?.principal?.type ?? 'user',
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** True when `action` matches any pattern in the deny list. */
|
|
87
|
+
function isDenied(deny: string[], action: string): boolean {
|
|
88
|
+
for (const pattern of deny) {
|
|
89
|
+
if (matchAction(pattern, action)) {
|
|
90
|
+
return true
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Read a single cookie value out of a request's Cookie header. Returns null when absent. */
|
|
97
|
+
function readCookie(req: Request, name: string): string | null {
|
|
98
|
+
const header = req.headers.get('Cookie')
|
|
99
|
+
if (!header) {
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
for (const part of header.split(';')) {
|
|
103
|
+
const eq = part.indexOf('=')
|
|
104
|
+
if (eq === -1) {
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
if (part.slice(0, eq).trim() === name) {
|
|
108
|
+
return decodeURIComponent(part.slice(eq + 1).trim())
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Fake surfaces ───────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
/** Allow-all-except-deny context (simple mode). */
|
|
117
|
+
class FakeAuthContext implements AuthContext {
|
|
118
|
+
readonly ok = true
|
|
119
|
+
readonly principal: PrincipalIdentity
|
|
120
|
+
|
|
121
|
+
constructor(
|
|
122
|
+
private readonly deny: string[],
|
|
123
|
+
identity: FakeIdentity,
|
|
124
|
+
) {
|
|
125
|
+
this.principal = { id: identity.id, type: identity.type, label: identity.label }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
can(action: string, _scope?: { project?: string }): boolean {
|
|
129
|
+
// Allow everything except denied actions — regardless of scope.
|
|
130
|
+
return !isDenied(this.deny, action)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
scopedTo(_action: string, _dimension = 'project'): string[] | null {
|
|
134
|
+
// Unrestricted: the fake identity may see everything.
|
|
135
|
+
return null
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
audit(_event: DomainEvent): Promise<void> {
|
|
139
|
+
return Promise.resolve()
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Persona-backed context (persona mode) — real `permits` / `scopedProjects` semantics. */
|
|
144
|
+
class PersonaAuthContext implements AuthContext {
|
|
145
|
+
readonly ok = true
|
|
146
|
+
readonly principal: PrincipalIdentity
|
|
147
|
+
|
|
148
|
+
constructor(private readonly persona: FakePersona) {
|
|
149
|
+
this.principal = { id: persona.id, type: persona.type ?? 'user', label: persona.label }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
can(action: string, scope?: { project?: string }): boolean {
|
|
153
|
+
return permits(this.persona.permissions, action, scope?.project)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
scopedTo(action: string, _dimension = 'project'): string[] | null {
|
|
157
|
+
return scopedProjects(this.persona.permissions, action)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
audit(_event: DomainEvent): Promise<void> {
|
|
161
|
+
return Promise.resolve()
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
class FakeCapability implements Capability {
|
|
166
|
+
readonly ok = true
|
|
167
|
+
|
|
168
|
+
constructor(private readonly deny: string[]) {}
|
|
169
|
+
|
|
170
|
+
can(action: string, _resource: string): boolean {
|
|
171
|
+
return !isDenied(this.deny, action)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
audit(_event: DomainEvent): Promise<void> {
|
|
175
|
+
return Promise.resolve()
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── FakeIamClient ─────────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Drop-in for `IamClient` with an identical public interface, selectable by the app via an env
|
|
183
|
+
* flag for `wrangler dev`. No Access, no IAM Worker. In SIMPLE mode `can()` allows everything
|
|
184
|
+
* except the `deny` list; in PERSONA mode it impersonates the request's selected persona with
|
|
185
|
+
* real permission semantics (see `FakeIamConfig`).
|
|
186
|
+
*/
|
|
187
|
+
export class FakeIamClient {
|
|
188
|
+
private readonly deny: string[]
|
|
189
|
+
private readonly identity: FakeIdentity
|
|
190
|
+
private readonly personas?: Record<string, FakePersona>
|
|
191
|
+
private readonly personaCookie: string
|
|
192
|
+
private readonly personaHeader: string
|
|
193
|
+
private readonly defaultPersona?: string
|
|
194
|
+
private readonly resolve?: (req: Request) => FakePersona | null | Promise<FakePersona | null>
|
|
195
|
+
// In-memory capability registry so issue → redeem → revoke stay consistent in dev/tests
|
|
196
|
+
// (no IAM Worker locally). Maps the plaintext token to its id, tracks every issued id,
|
|
197
|
+
// and the subset that has been revoked — so a redeemed-then-revoked token reads 'revoked'.
|
|
198
|
+
private readonly issuedTokens = new Map<string, string>()
|
|
199
|
+
private readonly issuedIds = new Set<string>()
|
|
200
|
+
private readonly revokedIds = new Set<string>()
|
|
201
|
+
|
|
202
|
+
constructor(config?: FakeIamConfig) {
|
|
203
|
+
this.deny = config?.deny ?? []
|
|
204
|
+
this.identity = resolveIdentity(config)
|
|
205
|
+
this.personas = config?.personas
|
|
206
|
+
this.personaCookie = config?.personaCookie ?? DEFAULT_PERSONA_COOKIE
|
|
207
|
+
this.personaHeader = config?.personaHeader ?? DEFAULT_PERSONA_HEADER
|
|
208
|
+
this.defaultPersona = config?.defaultPersona
|
|
209
|
+
this.resolve = config?.resolve
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async authenticate(req: Request): Promise<AuthContext | AuthFailure> {
|
|
213
|
+
// Dynamic resolver wins — the app decides the persona per request.
|
|
214
|
+
if (this.resolve) {
|
|
215
|
+
const persona = await this.resolve(req)
|
|
216
|
+
if (!persona) {
|
|
217
|
+
return { ok: false, reason: 'unknown_principal', status: 403 }
|
|
218
|
+
}
|
|
219
|
+
return new PersonaAuthContext(persona)
|
|
220
|
+
}
|
|
221
|
+
if (this.personas) {
|
|
222
|
+
const key = readCookie(req, this.personaCookie) ?? req.headers.get(this.personaHeader) ?? this.defaultPersona
|
|
223
|
+
const persona = key ? this.personas[key] : undefined
|
|
224
|
+
if (!persona) {
|
|
225
|
+
// Selected an unknown persona (or none, with no default) → behave like a real
|
|
226
|
+
// authenticated-but-unrecognised principal.
|
|
227
|
+
return { ok: false, reason: 'unknown_principal', status: 403 }
|
|
228
|
+
}
|
|
229
|
+
return new PersonaAuthContext(persona)
|
|
230
|
+
}
|
|
231
|
+
return new FakeAuthContext(this.deny, this.identity)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
redeemCapability(_req: Request, token: string): Promise<Capability | CapabilityFailure> {
|
|
235
|
+
// A token issued by THIS fake and since revoked reads 'revoked' (404), like the real
|
|
236
|
+
// Worker. Tokens we never issued (e.g. a hand-written one) still redeem allow-all — the
|
|
237
|
+
// fake is a dev/test convenience, not a validator.
|
|
238
|
+
const id = this.issuedTokens.get(token)
|
|
239
|
+
if (id && this.revokedIds.has(id)) {
|
|
240
|
+
return Promise.resolve({ ok: false, reason: 'revoked', status: 404 })
|
|
241
|
+
}
|
|
242
|
+
return Promise.resolve(new FakeCapability(this.deny))
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
issueCapability(_req: Request, _input: IssueCapabilityRequest): Promise<IssuedCapability | IssueFailure> {
|
|
246
|
+
const suffix = crypto.randomUUID()
|
|
247
|
+
const token = `fake-token-${suffix}`
|
|
248
|
+
const id = `fake-${this.identity.id}-${suffix}`
|
|
249
|
+
this.issuedTokens.set(token, id)
|
|
250
|
+
this.issuedIds.add(id)
|
|
251
|
+
return Promise.resolve({ ok: true, token, id })
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
revokeCapability(_req: Request, tokenId: string): Promise<RevokedCapability | RevokeFailure> {
|
|
255
|
+
if (!this.issuedIds.has(tokenId)) {
|
|
256
|
+
return Promise.resolve({ ok: false, reason: 'not_found', status: 404 })
|
|
257
|
+
}
|
|
258
|
+
if (this.revokedIds.has(tokenId)) {
|
|
259
|
+
return Promise.resolve({ ok: true, revoked: false })
|
|
260
|
+
}
|
|
261
|
+
this.revokedIds.add(tokenId)
|
|
262
|
+
return Promise.resolve({ ok: true, revoked: true })
|
|
263
|
+
}
|
|
264
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// @propustka/client — the app-facing IAM SDK (the only package published to npm).
|
|
2
|
+
//
|
|
3
|
+
// Depends ONLY on @propustka/core: the binding is typed as the `IamRpc` contract (so the SDK
|
|
4
|
+
// never imports the Worker), and `can()`/`scopedTo()` reuse core's `permits`/`matchAction`.
|
|
5
|
+
|
|
6
|
+
export { IamClient } from './client'
|
|
7
|
+
export { FakeIamClient } from './fake'
|
|
8
|
+
export type { FakeIamConfig, FakePersona } from './fake'
|
|
9
|
+
export { applyScope } from './scope'
|
|
10
|
+
export type {
|
|
11
|
+
AuthContext,
|
|
12
|
+
AuthFailure,
|
|
13
|
+
Capability,
|
|
14
|
+
CapabilityFailure,
|
|
15
|
+
IssueCapabilityRequest,
|
|
16
|
+
IssuedCapability,
|
|
17
|
+
IssueFailure,
|
|
18
|
+
PrincipalIdentity,
|
|
19
|
+
RevokedCapability,
|
|
20
|
+
RevokeFailure,
|
|
21
|
+
} from './types'
|
|
22
|
+
|
|
23
|
+
// Re-export from core so apps need only depend on the SDK: DomainEvent (one event shape) and
|
|
24
|
+
// IamRpc (the binding contract — apps type their `env.IAM` as IamRpc without importing core).
|
|
25
|
+
export type { DomainEvent, IamRpc } from '@propustka/core'
|
package/src/request.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Reading the forwarded Access credentials off the incoming Request. Mirrors the
|
|
2
|
+
// extraction the IAM Worker's admin router does, so both sides agree on exactly which
|
|
3
|
+
// header/cookie carries what. None of this is a security boundary — it's plumbing that
|
|
4
|
+
// hands the verified-at-the-edge token to the IAM Worker, which validates it.
|
|
5
|
+
|
|
6
|
+
/** Cloudflare Access injects the app token as this header on every request behind Access. */
|
|
7
|
+
const ACCESS_TOKEN_HEADER = 'Cf-Access-Jwt-Assertion'
|
|
8
|
+
/** The browser carries the Access session as this cookie; needed for get-identity (users). */
|
|
9
|
+
const ACCESS_COOKIE_NAME = 'CF_Authorization'
|
|
10
|
+
/** Cloudflare's per-request ray id; we use it as the correlation request id. */
|
|
11
|
+
const RAY_HEADER = 'cf-ray'
|
|
12
|
+
|
|
13
|
+
export interface ForwardedCredentials {
|
|
14
|
+
/** Cf-Access-Jwt-Assertion value, or null if absent (no Access in front). */
|
|
15
|
+
token: string | null
|
|
16
|
+
/** CF_Authorization cookie value parsed from the Cookie header, or null. */
|
|
17
|
+
cookie: string | null
|
|
18
|
+
/** The app's own origin (scheme + host), for the IAM Worker's get-identity call. */
|
|
19
|
+
origin: string
|
|
20
|
+
/** cf-ray, or a fresh UUID when absent (e.g. local dev). */
|
|
21
|
+
requestId: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Pull the forwarded Access credentials and a correlation id off the request. The token
|
|
26
|
+
* and cookie may be null (e.g. a Bypass path with no Access in front); the IAM Worker
|
|
27
|
+
* decides what that means.
|
|
28
|
+
*/
|
|
29
|
+
export function readCredentials(req: Request): ForwardedCredentials {
|
|
30
|
+
return {
|
|
31
|
+
token: req.headers.get(ACCESS_TOKEN_HEADER),
|
|
32
|
+
cookie: parseCookie(req.headers.get('Cookie'), ACCESS_COOKIE_NAME),
|
|
33
|
+
origin: new URL(req.url).origin,
|
|
34
|
+
requestId: req.headers.get(RAY_HEADER) ?? crypto.randomUUID(),
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Read a single cookie value out of a raw Cookie header. Returns null when absent. */
|
|
39
|
+
function parseCookie(header: string | null, name: string): string | null {
|
|
40
|
+
if (!header) {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
for (const part of header.split(';')) {
|
|
44
|
+
const eq = part.indexOf('=')
|
|
45
|
+
if (eq === -1) {
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
if (part.slice(0, eq).trim() === name) {
|
|
49
|
+
return part.slice(eq + 1).trim()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null
|
|
53
|
+
}
|
package/src/scope.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the three-state scope from `scopedTo()` into a single value the app controls.
|
|
3
|
+
* This is the SINGLE place the empty-`IN ()` SQL trap is handled: `none` short-circuits
|
|
4
|
+
* without ever emitting `WHERE id IN ()`.
|
|
5
|
+
*
|
|
6
|
+
* - `null` → `all()` — unrestricted (admin / global grant): no filter
|
|
7
|
+
* - `[]` → `none()` — no access: empty result, DO NOT query
|
|
8
|
+
* - non-empty → `some(ids)` — filter to these ids (`WHERE id IN (...)`)
|
|
9
|
+
*
|
|
10
|
+
* The app supplies what all/some/none mean for its own query; filtering must happen at the
|
|
11
|
+
* data layer, never by loading everything and filtering in memory.
|
|
12
|
+
*/
|
|
13
|
+
export function applyScope<T>(
|
|
14
|
+
scope: string[] | null,
|
|
15
|
+
branches: { all: () => T; some: (ids: string[]) => T; none: () => T },
|
|
16
|
+
): T {
|
|
17
|
+
if (scope === null) {
|
|
18
|
+
return branches.all()
|
|
19
|
+
}
|
|
20
|
+
if (scope.length === 0) {
|
|
21
|
+
return branches.none()
|
|
22
|
+
}
|
|
23
|
+
return branches.some(scope)
|
|
24
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { DomainEvent, IssueCapabilityGrant, PrincipalType } from '@propustka/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The resolved caller's identity — who Access says you are, as the IAM Worker recorded
|
|
5
|
+
* the principal. Exposed on `AuthContext` so apps can stamp domain rows (created_by,
|
|
6
|
+
* activity actor, assignee) and render "signed in as …" without a second lookup. It is
|
|
7
|
+
* NOT a permission surface — authorization is still `can()` / `scopedTo()`.
|
|
8
|
+
* - `id` — the IAM principal id (UUIDv7); stable, safe to store on domain rows.
|
|
9
|
+
* - `type` — 'user' (Access identity login) or 'service' (Access service token).
|
|
10
|
+
* - `label` — human-readable: the user's email, or the service token's name.
|
|
11
|
+
*/
|
|
12
|
+
export interface PrincipalIdentity {
|
|
13
|
+
readonly id: string
|
|
14
|
+
readonly type: PrincipalType
|
|
15
|
+
readonly label: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ── Result surfaces ──────────────────────────────────────────────────────────
|
|
19
|
+
//
|
|
20
|
+
// Every method returns a discriminated union whose members all carry an `ok` field,
|
|
21
|
+
// so app code branches once on `result.ok`. The ok:true members are the rich
|
|
22
|
+
// `AuthContext` / `Capability` / `IssuedCapability` surfaces; the ok:false members are
|
|
23
|
+
// the typed failures below, each carrying the HTTP status the app should return.
|
|
24
|
+
//
|
|
25
|
+
// The ok:true surfaces are modelled as *interfaces* (not concrete classes) so that both
|
|
26
|
+
// the real (`permits`-backed) and fake (allow-all-except-deny) implementations satisfy a
|
|
27
|
+
// single shared shape without any casts — see the design note in the package README of the
|
|
28
|
+
// spec. The documented surface (`ok` / `can` / `scopedTo` / `audit`) is the interface.
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The authenticated, ok:true result. `can`/`scopedTo` are local & pure (no binding call);
|
|
32
|
+
* only `audit` reaches the IAM Worker.
|
|
33
|
+
*/
|
|
34
|
+
export interface AuthContext {
|
|
35
|
+
readonly ok: true
|
|
36
|
+
/**
|
|
37
|
+
* The resolved caller identity (id / type / label). For stamping domain rows and
|
|
38
|
+
* display — NOT a permission surface (authorization stays `can` / `scopedTo`).
|
|
39
|
+
*/
|
|
40
|
+
readonly principal: PrincipalIdentity
|
|
41
|
+
/**
|
|
42
|
+
* Point check: may this principal do `action` (optionally on `scope.project`)?
|
|
43
|
+
* Scope-less → satisfied by GLOBAL permissions only; a project-scoped grant never
|
|
44
|
+
* widens into a scope-less allow.
|
|
45
|
+
*/
|
|
46
|
+
can(action: string, scope?: { project?: string }): boolean
|
|
47
|
+
/**
|
|
48
|
+
* Scoping: which project ids may this principal perform `action` on?
|
|
49
|
+
* - `null` → unrestricted (holds the action globally — e.g. an admin)
|
|
50
|
+
* - `[]` → no project access at all
|
|
51
|
+
* - non-empty → exactly those project ids
|
|
52
|
+
* Consume via `applyScope` so the empty-`IN ()` trap is handled once. `dimension`
|
|
53
|
+
* defaults to `'project'` and is forward-looking only (v1 has a single scope dimension).
|
|
54
|
+
*/
|
|
55
|
+
scopedTo(action: string, dimension?: string): string[] | null
|
|
56
|
+
/** Emit a domain audit event; app/principal/requestId are auto-attached. Fire-and-forget. */
|
|
57
|
+
audit(event: DomainEvent): Promise<void>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* An anonymous redeemed capability token. Same `can` ergonomics as `AuthContext`, but
|
|
62
|
+
* EXACT (action, resource) matching — no wildcards, no project scope.
|
|
63
|
+
*/
|
|
64
|
+
export interface Capability {
|
|
65
|
+
readonly ok: true
|
|
66
|
+
/** Exact match against the token's (action, resource) list. No wildcards. */
|
|
67
|
+
can(action: string, resource: string): boolean
|
|
68
|
+
/** Emit a domain audit event; capabilityTokenId + token label attached, principalId null. */
|
|
69
|
+
audit(event: DomainEvent): Promise<void>
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Successful `issueCapability` — plaintext token returned ONCE. */
|
|
73
|
+
export interface IssuedCapability {
|
|
74
|
+
readonly ok: true
|
|
75
|
+
/** Plaintext token — show once, never persist. */
|
|
76
|
+
token: string
|
|
77
|
+
/** The capability token id (safe to store/reference). */
|
|
78
|
+
id: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Successful `revokeCapability`. `revoked` is false when the token was already revoked. */
|
|
82
|
+
export interface RevokedCapability {
|
|
83
|
+
readonly ok: true
|
|
84
|
+
/** True when this call flipped the token to revoked; false if it was already revoked (idempotent). */
|
|
85
|
+
revoked: boolean
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Failures ─────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
/** `authenticate` failure. missing/invalid → 401; unknown_principal/disabled → 403. */
|
|
91
|
+
export interface AuthFailure {
|
|
92
|
+
readonly ok: false
|
|
93
|
+
reason: 'missing_token' | 'invalid_token' | 'unknown_principal' | 'disabled'
|
|
94
|
+
status: 401 | 403
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** `redeemCapability` failure — a bad/expired share link reads as 404. */
|
|
98
|
+
export interface CapabilityFailure {
|
|
99
|
+
readonly ok: false
|
|
100
|
+
reason: 'unknown' | 'expired' | 'revoked' | 'exhausted'
|
|
101
|
+
status: 404
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** `issueCapability` failure. missing/invalid → 401; unknown_principal/disabled/not_allowed → 403. */
|
|
105
|
+
export interface IssueFailure {
|
|
106
|
+
readonly ok: false
|
|
107
|
+
reason: 'missing_token' | 'invalid_token' | 'unknown_principal' | 'disabled' | 'not_allowed'
|
|
108
|
+
status: 401 | 403
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** `revokeCapability` failure. missing/invalid → 401; unknown/disabled/not_allowed → 403; not_found → 404. */
|
|
112
|
+
export interface RevokeFailure {
|
|
113
|
+
readonly ok: false
|
|
114
|
+
reason: 'missing_token' | 'invalid_token' | 'unknown_principal' | 'disabled' | 'not_allowed' | 'not_found'
|
|
115
|
+
status: 401 | 403 | 404
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Inputs ───────────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* App-supplied portion of `issueCapability` — the grants/label/expiry/maxUses. The SDK
|
|
122
|
+
* fills `app` and the issuer's forwarded credentials (token/cookie/origin/requestId) from
|
|
123
|
+
* the request, so app code can never self-assert the issuer.
|
|
124
|
+
*/
|
|
125
|
+
export interface IssueCapabilityRequest {
|
|
126
|
+
grants: IssueCapabilityGrant[]
|
|
127
|
+
label?: string
|
|
128
|
+
expiresAt?: number
|
|
129
|
+
maxUses?: number
|
|
130
|
+
}
|