@propustka/core 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 +28 -0
- package/src/ids.ts +41 -0
- package/src/index.ts +6 -0
- package/src/permissions.ts +75 -0
- package/src/rpc.ts +120 -0
- package/src/types.ts +43 -0
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@propustka/core",
|
|
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/core"
|
|
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
|
+
}
|
package/src/ids.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate a UUIDv7 (RFC 9562): time-sortable, 128 bits.
|
|
3
|
+
*
|
|
4
|
+
* Layout:
|
|
5
|
+
* - bytes 0..5 : 48-bit big-endian Unix-millis timestamp
|
|
6
|
+
* - byte 6 : version nibble `7` in the high 4 bits, random in the low 4
|
|
7
|
+
* - byte 8 : RFC 4122 variant bits `10` in the high 2 bits, random in the low 6
|
|
8
|
+
* - the remaining 74 bits are random (`crypto.getRandomValues`)
|
|
9
|
+
*
|
|
10
|
+
* Because the timestamp occupies the most-significant bytes, two ids generated at
|
|
11
|
+
* different milliseconds sort lexicographically in time order. No dependency.
|
|
12
|
+
*/
|
|
13
|
+
export function uuidv7(): string {
|
|
14
|
+
const bytes = new Uint8Array(16)
|
|
15
|
+
|
|
16
|
+
// 48-bit big-endian Unix-millis timestamp in the first 6 bytes.
|
|
17
|
+
const millis = Date.now()
|
|
18
|
+
// `millis` fits in 48 bits (well under 2^53), so the high bits are zero.
|
|
19
|
+
bytes[0] = Math.floor(millis / 0x10000000000) & 0xff
|
|
20
|
+
bytes[1] = Math.floor(millis / 0x100000000) & 0xff
|
|
21
|
+
bytes[2] = Math.floor(millis / 0x1000000) & 0xff
|
|
22
|
+
bytes[3] = Math.floor(millis / 0x10000) & 0xff
|
|
23
|
+
bytes[4] = Math.floor(millis / 0x100) & 0xff
|
|
24
|
+
bytes[5] = millis & 0xff
|
|
25
|
+
|
|
26
|
+
// Fill the remaining 10 bytes (74 usable random bits + the nibble/variant slots) with randomness.
|
|
27
|
+
const random = new Uint8Array(10)
|
|
28
|
+
crypto.getRandomValues(random)
|
|
29
|
+
bytes.set(random, 6)
|
|
30
|
+
|
|
31
|
+
// Version: high nibble of byte 6 = 0b0111 (7).
|
|
32
|
+
bytes[6] = (bytes[6]! & 0x0f) | 0x70
|
|
33
|
+
// Variant: high two bits of byte 8 = 0b10.
|
|
34
|
+
bytes[8] = (bytes[8]! & 0x3f) | 0x80
|
|
35
|
+
|
|
36
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0'))
|
|
37
|
+
|
|
38
|
+
return `${hex[0]}${hex[1]}${hex[2]}${hex[3]}-${hex[4]}${hex[5]}-${hex[6]}${hex[7]}-${hex[8]}${hex[9]}-${hex[10]}${hex[11]}${hex[12]}${hex[13]}${
|
|
39
|
+
hex[14]
|
|
40
|
+
}${hex[15]}`
|
|
41
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// @propustka/core — the one shared library: pure logic & types that must not drift
|
|
2
|
+
// between @propustka/worker (implements it) and @propustka/client (consumes it).
|
|
3
|
+
export * from './ids'
|
|
4
|
+
export * from './permissions'
|
|
5
|
+
export * from './rpc'
|
|
6
|
+
export * from './types'
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { PermissionEntry } from './types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wildcard action matching. The pattern is one of:
|
|
5
|
+
* - '*' — matches every action
|
|
6
|
+
* - 'prefix.*' — matches the `prefix` namespace and anything nested under it
|
|
7
|
+
* (e.g. 'project.*' matches 'project.read' and 'project.settings.update',
|
|
8
|
+
* but NOT 'projects.read' — the boundary is the dot)
|
|
9
|
+
* - exact — matches only that exact action string
|
|
10
|
+
*
|
|
11
|
+
* Examples:
|
|
12
|
+
* matchAction('*', 'x.y') === true
|
|
13
|
+
* matchAction('project.*', 'project.read') === true
|
|
14
|
+
* matchAction('project.*', 'project.settings.update') === true
|
|
15
|
+
* matchAction('project.read', 'project.read') === true
|
|
16
|
+
* matchAction('project.read', 'project.write') === false
|
|
17
|
+
* matchAction('project.*', 'projects.read') === false
|
|
18
|
+
*/
|
|
19
|
+
export function matchAction(pattern: string, action: string): boolean {
|
|
20
|
+
if (pattern === '*') {
|
|
21
|
+
return true
|
|
22
|
+
}
|
|
23
|
+
if (pattern.endsWith('.*')) {
|
|
24
|
+
// Drop the trailing '*' (keep the dot) so 'project.*' yields 'project.' —
|
|
25
|
+
// the action must start with that prefix, which enforces the dot boundary.
|
|
26
|
+
const prefix = pattern.slice(0, -1)
|
|
27
|
+
return action.startsWith(prefix)
|
|
28
|
+
}
|
|
29
|
+
return pattern === action
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Does `entries` grant `action` at the given scope? Mirrors `can()` semantics exactly:
|
|
34
|
+
* - projectScope === undefined → scope-less: ONLY entries with projectId === null satisfy.
|
|
35
|
+
* - projectScope === <id> → entries with projectId === null OR projectId === <id> satisfy.
|
|
36
|
+
*
|
|
37
|
+
* Within the satisfying entries, the entry's `action` is matched against the requested
|
|
38
|
+
* `action` using `matchAction` (so a wildcard entry like 'project.*' grants 'project.read').
|
|
39
|
+
*/
|
|
40
|
+
export function permits(entries: PermissionEntry[], action: string, projectScope?: string): boolean {
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
const scopeOk = entry.projectId === null || entry.projectId === projectScope
|
|
43
|
+
if (scopeOk && matchAction(entry.action, action)) {
|
|
44
|
+
return true
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The set of project ids `entries` grant `action` on — the resolution behind `scopedTo()`,
|
|
52
|
+
* shared so the real SDK context and any fake/test context agree exactly:
|
|
53
|
+
* - `null` → unrestricted: a matching GLOBAL entry (projectId === null) exists, so the
|
|
54
|
+
* principal holds the action everywhere (e.g. an admin / app-wide grant);
|
|
55
|
+
* - non-empty → exactly the project ids of the matching project-scoped entries;
|
|
56
|
+
* - `[]` → no matching entry: no project access at all.
|
|
57
|
+
* A matching global entry short-circuits to `null` (it dominates any scoped entries).
|
|
58
|
+
*/
|
|
59
|
+
export function scopedProjects(entries: PermissionEntry[], action: string): string[] | null {
|
|
60
|
+
const ids: string[] = []
|
|
61
|
+
const seen = new Set<string>()
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
if (!matchAction(entry.action, action)) {
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
if (entry.projectId === null) {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
if (!seen.has(entry.projectId)) {
|
|
70
|
+
seen.add(entry.projectId)
|
|
71
|
+
ids.push(entry.projectId)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return ids
|
|
75
|
+
}
|
package/src/rpc.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { PermissionEntry, PrincipalType } from './types'
|
|
2
|
+
|
|
3
|
+
export interface AuthenticateInput {
|
|
4
|
+
/** Self-asserted caller app id; superseded by the aud-derived app id on valid tokens. */
|
|
5
|
+
app: string
|
|
6
|
+
/** Cf-Access-Jwt-Assertion header value. */
|
|
7
|
+
token: string | null
|
|
8
|
+
/** CF_Authorization cookie value, for get-identity (users only). */
|
|
9
|
+
cookie: string | null
|
|
10
|
+
/** The app's own origin (for get-identity). */
|
|
11
|
+
origin: string | null
|
|
12
|
+
requestId: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ResolvedPrincipal {
|
|
16
|
+
id: string
|
|
17
|
+
type: PrincipalType
|
|
18
|
+
label: string
|
|
19
|
+
permissions: PermissionEntry[]
|
|
20
|
+
requestId: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type AuthenticateResult =
|
|
24
|
+
/** get-identity failed → explicit grants only this request (`groupsUnavailable`). */
|
|
25
|
+
| { ok: true; principal: ResolvedPrincipal; groupsUnavailable?: true }
|
|
26
|
+
| { ok: false; reason: 'missing_token' | 'invalid_token' | 'unknown_principal' | 'disabled' }
|
|
27
|
+
|
|
28
|
+
export interface AuditInput {
|
|
29
|
+
app: string
|
|
30
|
+
requestId: string
|
|
31
|
+
/** NULL for capability-driven events. */
|
|
32
|
+
principalId: string | null
|
|
33
|
+
/** Snapshot — survives principal deletion (token label for capabilities). */
|
|
34
|
+
principalLabel: string
|
|
35
|
+
/** Set for capability-driven events. */
|
|
36
|
+
capabilityTokenId?: string
|
|
37
|
+
action: string
|
|
38
|
+
resourceType: string
|
|
39
|
+
resourceId?: string
|
|
40
|
+
diff?: unknown
|
|
41
|
+
metadata?: unknown
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface RedeemCapabilityInput {
|
|
45
|
+
app: string
|
|
46
|
+
token: string
|
|
47
|
+
requestId: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CapabilityGrant {
|
|
51
|
+
action: string
|
|
52
|
+
resource: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type RedeemCapabilityResult =
|
|
56
|
+
| { ok: true; capabilities: CapabilityGrant[]; tokenId: string; label: string | null }
|
|
57
|
+
| { ok: false; reason: 'unknown' | 'expired' | 'revoked' | 'exhausted' }
|
|
58
|
+
|
|
59
|
+
export interface IssueCapabilityGrant {
|
|
60
|
+
action: string
|
|
61
|
+
resource: string
|
|
62
|
+
/** Scope for the delegation check ONLY (not stored). Omitted → issuer must hold the action globally. */
|
|
63
|
+
projectId?: string | null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface IssueCapabilityInput {
|
|
67
|
+
app: string
|
|
68
|
+
/** The ISSUER's Access JWT — issuer is resolved server-side, never self-asserted. */
|
|
69
|
+
token: string | null
|
|
70
|
+
/** Issuer's CF_Authorization cookie (group-derived permissions count toward delegation too). */
|
|
71
|
+
cookie: string | null
|
|
72
|
+
origin: string | null
|
|
73
|
+
requestId: string
|
|
74
|
+
grants: IssueCapabilityGrant[]
|
|
75
|
+
label?: string
|
|
76
|
+
expiresAt?: number
|
|
77
|
+
maxUses?: number
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type IssueCapabilityResult =
|
|
81
|
+
/** Plaintext token returned ONCE. */
|
|
82
|
+
| { ok: true; token: string; id: string }
|
|
83
|
+
| { ok: false; reason: 'missing_token' | 'invalid_token' | 'unknown_principal' | 'disabled' | 'not_allowed' }
|
|
84
|
+
|
|
85
|
+
export interface RevokeCapabilityInput {
|
|
86
|
+
app: string
|
|
87
|
+
/** The CALLER's Access JWT — the authorizer is resolved server-side, never self-asserted. */
|
|
88
|
+
token: string | null
|
|
89
|
+
/** Caller's CF_Authorization cookie (group-derived permissions count toward the revoke check). */
|
|
90
|
+
cookie: string | null
|
|
91
|
+
origin: string | null
|
|
92
|
+
requestId: string
|
|
93
|
+
/** The capability token id (the `id` returned by issueCapability), NOT the plaintext token. */
|
|
94
|
+
tokenId: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type RevokeCapabilityResult =
|
|
98
|
+
/** `revoked` is false when the token was already revoked (idempotent). */
|
|
99
|
+
| { ok: true; revoked: boolean }
|
|
100
|
+
| { ok: false; reason: 'missing_token' | 'invalid_token' | 'unknown_principal' | 'disabled' | 'not_allowed' | 'not_found' }
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The RPC contract. The Worker's `WorkerEntrypoint` `implements IamRpc`; the SDK types its
|
|
104
|
+
* binding as `Service<IamRpc>` (so the SDK never imports the Worker). Methods return plain
|
|
105
|
+
* serializable objects (RPC-friendly).
|
|
106
|
+
*/
|
|
107
|
+
export interface IamRpc {
|
|
108
|
+
authenticate(input: AuthenticateInput): Promise<AuthenticateResult>
|
|
109
|
+
audit(event: AuditInput): Promise<void>
|
|
110
|
+
redeemCapability(input: RedeemCapabilityInput): Promise<RedeemCapabilityResult>
|
|
111
|
+
issueCapability(input: IssueCapabilityInput): Promise<IssueCapabilityResult>
|
|
112
|
+
/**
|
|
113
|
+
* Revoke a previously-issued capability token by id. The caller is resolved from the
|
|
114
|
+
* forwarded Access credentials and authorized: the original issuer may always revoke;
|
|
115
|
+
* otherwise the caller must hold every granted action globally (an admin / app-wide
|
|
116
|
+
* operator). Idempotent — revoking an already-revoked token returns `{ ok: true,
|
|
117
|
+
* revoked: false }`. An unknown id returns `{ ok: false, reason: 'not_found' }`.
|
|
118
|
+
*/
|
|
119
|
+
revokeCapability(input: RevokeCapabilityInput): Promise<RevokeCapabilityResult>
|
|
120
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export type PrincipalType = 'user' | 'service'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where a resolved permission entry came from. Used for debuggability in the admin UI
|
|
5
|
+
* ("why does this user have this permission?"); `can()`/`scopedTo()` ignore it.
|
|
6
|
+
* - 'grant' — explicit `grants` row
|
|
7
|
+
* - 'bootstrap' — IAM_BOOTSTRAP_ADMINS env match (resolution-time only)
|
|
8
|
+
* - `group:${groupRef}` — IdP group → role mapping (the `<org>/<team>` ref)
|
|
9
|
+
*/
|
|
10
|
+
export type PermissionSource = 'grant' | 'bootstrap' | `group:${string}`
|
|
11
|
+
|
|
12
|
+
export interface PermissionEntry {
|
|
13
|
+
action: string
|
|
14
|
+
/** null = global / all projects */
|
|
15
|
+
projectId: string | null
|
|
16
|
+
source: PermissionSource
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Domain event apps emit. Only the app knows what changed.
|
|
21
|
+
*
|
|
22
|
+
* IMPORTANT: `diff`/`metadata` may carry sensitive values (settings can hold secrets).
|
|
23
|
+
* The app MUST redact secret material before passing them — audit storage is verbatim
|
|
24
|
+
* and long-lived; the IAM Worker stores what it receives as-is.
|
|
25
|
+
*/
|
|
26
|
+
export interface DomainEvent {
|
|
27
|
+
action: string
|
|
28
|
+
resourceType: string
|
|
29
|
+
resourceId?: string
|
|
30
|
+
diff?: unknown
|
|
31
|
+
metadata?: unknown
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RoleDef {
|
|
35
|
+
name: string
|
|
36
|
+
description?: string
|
|
37
|
+
permissions: string[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RoleSource {
|
|
41
|
+
getRole(key: string): RoleDef | undefined
|
|
42
|
+
listRoles(): Record<string, RoleDef>
|
|
43
|
+
}
|