@teikyo/access 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +78 -2
- package/lib/grants/cache.d.ts +21 -0
- package/lib/grants/cache.js +106 -0
- package/lib/grants/migrations.d.ts +2 -0
- package/lib/grants/migrations.js +16 -0
- package/lib/grants/plugin.d.ts +16 -0
- package/lib/grants/plugin.js +48 -0
- package/lib/grants/store.d.ts +42 -0
- package/lib/grants/store.js +105 -0
- package/lib/grants/tables.d.ts +9 -0
- package/lib/grants/tables.js +1 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.js +6 -0
- package/lib/patterns.d.ts +4 -0
- package/lib/patterns.js +11 -0
- package/lib/revocation/backend.d.ts +27 -0
- package/lib/revocation/backend.js +85 -0
- package/lib/revocation/migrations.d.ts +2 -0
- package/lib/revocation/migrations.js +15 -0
- package/lib/revocation/plugin.d.ts +16 -0
- package/lib/revocation/plugin.js +31 -0
- package/lib/revocation/tables.d.ts +11 -0
- package/lib/revocation/tables.js +1 -0
- package/package.json +50 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Paul Le Cam
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,79 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @teikyo/access
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Access grants and capability revocation for Enkaku servers on `@sozai/http-server`. It provides the `access:grants` and `access:revocation` plugins (exported as `ACCESS_GRANTS` and `ACCESS_REVOCATION`), both backed by the `hozon:db` plugin from `@teikyo/hozon`, plus a short-lived grant cache and the access pattern helpers.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pnpm add @teikyo/access @sozai/http-server hono
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Grants plugin
|
|
12
|
+
|
|
13
|
+
`accessGrantsPlugin()` provides the grant store and an `allow()` predicate for Enkaku access rules. It is normally consumed through `@teikyo/enkaku`'s `access.grants` option.
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { createServer } from '@sozai/http-server'
|
|
17
|
+
import { accessGrantsPlugin, accessRevocationPlugin } from '@teikyo/access'
|
|
18
|
+
import { hozonDBPlugin } from '@teikyo/hozon'
|
|
19
|
+
|
|
20
|
+
const server = await createServer({
|
|
21
|
+
plugins: [
|
|
22
|
+
hozonDBPlugin({ db: './app.db' }),
|
|
23
|
+
accessGrantsPlugin({ cacheTTLMs: 5000 }),
|
|
24
|
+
accessRevocationPlugin({ requireJTI: true }),
|
|
25
|
+
],
|
|
26
|
+
})
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
| Option | Type | Description |
|
|
30
|
+
| --- | --- | --- |
|
|
31
|
+
| `accessGrantsPlugin({ cacheTTLMs })` | `number` (optional) | Maximum age of cached grants in milliseconds, 5000 by default. Must be a non-negative integer (0 disables caching); anything else throws `Invalid cacheTTLMs`. |
|
|
32
|
+
| `accessRevocationPlugin({ requireJTI })` | `boolean` (optional) | Reject capabilities without a `jti`. Defaults to `true`. It also accepts the DID resolution options (`resolver`, `cache`, `methods`). |
|
|
33
|
+
|
|
34
|
+
### Rule composition
|
|
35
|
+
|
|
36
|
+
The `allow()` predicate admits a caller when any of these holds:
|
|
37
|
+
|
|
38
|
+
1. the token issuer holds a grant matching the procedure;
|
|
39
|
+
2. the token `sub` holds a matching grant and the delegation chain verifies, so a delegate acts with the grants of the subject that delegated to it.
|
|
40
|
+
|
|
41
|
+
Anything else is denied, and a store failure is logged and denied with `Access denied`. When used through `@teikyo/enkaku`, the gated patterns get this predicate and caller-supplied rules must not overlap them (see that package).
|
|
42
|
+
|
|
43
|
+
### Operator capability
|
|
44
|
+
|
|
45
|
+
Enkaku handles two server-identity cases before access rules are consulted, and neither calls the grants predicate or needs a grant:
|
|
46
|
+
|
|
47
|
+
- A token whose `sub` is the server identity is checked directly as a capability. The `verifyToken` hook still runs on its chain, so with `access.revocation` enabled the capability can be revoked, and while `requireJTI` is `true` it must carry a `jti`.
|
|
48
|
+
- A token whose `iss` is the server identity itself bypasses both the rules and the revocation hook.
|
|
49
|
+
|
|
50
|
+
Issue such capabilities and keys only to operators.
|
|
51
|
+
|
|
52
|
+
### Cache delay across processes
|
|
53
|
+
|
|
54
|
+
`GrantCache` drops a subject's entry as soon as the store in the same process reports a change. A grant created or revoked by another process sharing the database is only seen once the cached entry expires, so the delay is at most one cache TTL (`cacheTTLMs`, 5 s by default).
|
|
55
|
+
|
|
56
|
+
## Store and cache
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { GRANT_STORE, GrantCache, type GrantStore, grantStoreDefinition } from '@teikyo/access'
|
|
60
|
+
|
|
61
|
+
db.register(grantStoreDefinition)
|
|
62
|
+
const store = await db.getStore<GrantStore>(GRANT_STORE)
|
|
63
|
+
await store.grant({ subject: 'did:key:z6Mk...', pattern: 'app/*', createdBy: 'did:key:z6Mk...' })
|
|
64
|
+
|
|
65
|
+
const cache = new GrantCache({ store })
|
|
66
|
+
await cache.allows('did:key:z6Mk...', 'app/read')
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
- `GrantStore` upserts grants by `(subject, pattern)`, normalises subject DIDs and validates patterns.
|
|
70
|
+
- `GrantCache` holds active grants per subject for `ttlMs` (default 5000), or until the earliest grant expiry, and drops a subject's entry as soon as the store reports a change.
|
|
71
|
+
- `patternMatches(pattern, procedure)` uses the matcher Enkaku applies to access rules; `patternsOverlap(a, b)` reports whether two patterns can cover a common procedure.
|
|
72
|
+
|
|
73
|
+
## Revocation
|
|
74
|
+
|
|
75
|
+
`accessRevocationPlugin()` (plugin name `access:revocation`) provides a Hozon-backed revocation store and a `verifyToken` hook for Enkaku servers.
|
|
76
|
+
|
|
77
|
+
- Records are scoped by issuer: the store's `get(jti, issuer)` only returns records signed by `issuer`, and the hook looks up the capability's own issuer, so another identity cannot revoke or shadow a jti it does not own.
|
|
78
|
+
- `requireJTI` defaults to `true`, so capabilities created without an explicit `jti` are rejected with `Capability without jti rejected`; set it to `false` to accept them (they can never be revoked).
|
|
79
|
+
- Register the store through the plugin, which carries the DID resolution options (`resolver`, `cache`, `methods`) that `add` needs.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { GrantStore } from './store.js';
|
|
2
|
+
export type GrantCacheParams = {
|
|
3
|
+
store: GrantStore;
|
|
4
|
+
/** Maximum age of a cached entry in milliseconds, a non-negative integer (0 disables caching). Defaults to 5000. */
|
|
5
|
+
ttlMs?: number;
|
|
6
|
+
/** Maximum number of cached subjects; the least recently used is evicted first. Defaults to 10000. */
|
|
7
|
+
maxEntries?: number;
|
|
8
|
+
/** Clock override, returning epoch milliseconds. */
|
|
9
|
+
now?: () => number;
|
|
10
|
+
};
|
|
11
|
+
/** Whether `ttlMs` is a usable cache lifetime: a non-negative safe integer (0 disables caching). */
|
|
12
|
+
export declare function isValidTTL(ttlMs: number): boolean;
|
|
13
|
+
/** Per-subject cache of active grants, invalidated by store changes and grant expiry. */
|
|
14
|
+
export declare class GrantCache {
|
|
15
|
+
#private;
|
|
16
|
+
constructor(params: GrantCacheParams);
|
|
17
|
+
allows(subject: string, procedure: string): Promise<boolean>;
|
|
18
|
+
/** Stop listening to the store and drop all cached state. */
|
|
19
|
+
dispose(): void;
|
|
20
|
+
[Symbol.dispose](): void;
|
|
21
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { normalizeDID } from '@kokuin/token';
|
|
2
|
+
import { patternMatches } from '../patterns.js';
|
|
3
|
+
const DEFAULT_TTL_MS = 5000;
|
|
4
|
+
const DEFAULT_MAX_ENTRIES = 10_000;
|
|
5
|
+
/** Whether `ttlMs` is a usable cache lifetime: a non-negative safe integer (0 disables caching). */ export function isValidTTL(ttlMs) {
|
|
6
|
+
return Number.isSafeInteger(ttlMs) && ttlMs >= 0;
|
|
7
|
+
}
|
|
8
|
+
/** Per-subject cache of active grants, invalidated by store changes and grant expiry. */ export class GrantCache {
|
|
9
|
+
#store;
|
|
10
|
+
#ttlMs;
|
|
11
|
+
#maxEntries;
|
|
12
|
+
#now;
|
|
13
|
+
#entries = new Map();
|
|
14
|
+
#refills = new Map();
|
|
15
|
+
#unsubscribe;
|
|
16
|
+
#disposed = false;
|
|
17
|
+
constructor(params){
|
|
18
|
+
const maxEntries = params.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
19
|
+
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
|
|
20
|
+
throw new Error('Invalid maxEntries');
|
|
21
|
+
}
|
|
22
|
+
// `Infinity` would keep entries for grants without expiry forever, so changes made by another
|
|
23
|
+
// process would never be observed; `NaN` or a negative value would silently disable the cache.
|
|
24
|
+
const ttlMs = params.ttlMs ?? DEFAULT_TTL_MS;
|
|
25
|
+
if (!isValidTTL(ttlMs)) {
|
|
26
|
+
throw new Error('Invalid ttlMs');
|
|
27
|
+
}
|
|
28
|
+
this.#store = params.store;
|
|
29
|
+
this.#ttlMs = ttlMs;
|
|
30
|
+
this.#maxEntries = maxEntries;
|
|
31
|
+
this.#now = params.now ?? Date.now;
|
|
32
|
+
this.#unsubscribe = this.#store.onChange((subject)=>{
|
|
33
|
+
this.#entries.delete(subject);
|
|
34
|
+
this.#refills.delete(subject);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
#grantsFor(subject) {
|
|
38
|
+
const now = this.#now();
|
|
39
|
+
const cached = this.#entries.get(subject);
|
|
40
|
+
if (cached != null) {
|
|
41
|
+
if (now < cached.expiresAt) {
|
|
42
|
+
// Re-insert to mark as most recently used.
|
|
43
|
+
this.#entries.delete(subject);
|
|
44
|
+
this.#entries.set(subject, cached);
|
|
45
|
+
return cached.grants;
|
|
46
|
+
}
|
|
47
|
+
this.#entries.delete(subject);
|
|
48
|
+
}
|
|
49
|
+
const pending = this.#refills.get(subject);
|
|
50
|
+
if (pending != null) {
|
|
51
|
+
return pending.promise;
|
|
52
|
+
}
|
|
53
|
+
const token = Symbol('refill');
|
|
54
|
+
const isCurrent = ()=>this.#refills.get(subject)?.token === token;
|
|
55
|
+
const promise = this.#store.activeGrants(subject, now).then((grants)=>{
|
|
56
|
+
if (isCurrent()) {
|
|
57
|
+
this.#refills.delete(subject);
|
|
58
|
+
let expiresAt = now + this.#ttlMs;
|
|
59
|
+
for (const grant of grants){
|
|
60
|
+
if (grant.expiresAt != null && grant.expiresAt < expiresAt) {
|
|
61
|
+
expiresAt = grant.expiresAt;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
this.#remember(subject, {
|
|
65
|
+
grants,
|
|
66
|
+
expiresAt
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return grants;
|
|
70
|
+
}, (error)=>{
|
|
71
|
+
if (isCurrent()) {
|
|
72
|
+
this.#refills.delete(subject);
|
|
73
|
+
}
|
|
74
|
+
throw error;
|
|
75
|
+
});
|
|
76
|
+
this.#refills.set(subject, {
|
|
77
|
+
promise,
|
|
78
|
+
token
|
|
79
|
+
});
|
|
80
|
+
return promise;
|
|
81
|
+
}
|
|
82
|
+
#remember(subject, entry) {
|
|
83
|
+
this.#entries.set(subject, entry);
|
|
84
|
+
while(this.#entries.size > this.#maxEntries){
|
|
85
|
+
const oldest = this.#entries.keys().next();
|
|
86
|
+
if (oldest.done) break;
|
|
87
|
+
this.#entries.delete(oldest.value);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async allows(subject, procedure) {
|
|
91
|
+
if (this.#disposed) {
|
|
92
|
+
throw new Error('GrantCache is disposed');
|
|
93
|
+
}
|
|
94
|
+
const grants = await this.#grantsFor(normalizeDID(subject));
|
|
95
|
+
return grants.some((grant)=>patternMatches(grant.pattern, procedure));
|
|
96
|
+
}
|
|
97
|
+
/** Stop listening to the store and drop all cached state. */ dispose() {
|
|
98
|
+
this.#disposed = true;
|
|
99
|
+
this.#unsubscribe();
|
|
100
|
+
this.#entries.clear();
|
|
101
|
+
this.#refills.clear();
|
|
102
|
+
}
|
|
103
|
+
[Symbol.dispose]() {
|
|
104
|
+
this.dispose();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function grantStoreMigrations(ctx) {
|
|
2
|
+
return {
|
|
3
|
+
'0-init': {
|
|
4
|
+
async up (db) {
|
|
5
|
+
await db.schema.createTable('grants').addColumn('subject', ctx.types.text, (column)=>column.notNull()).addColumn('pattern', ctx.types.text, (column)=>column.notNull()).addColumn('expires_at', ctx.types.bigint).addColumn('created_at', ctx.types.bigint, (column)=>column.notNull()).addColumn('created_by', ctx.types.text, (column)=>column.notNull()).addPrimaryKeyConstraint(`${ctx.tablePrefix}_grants_pkey`, [
|
|
6
|
+
'subject',
|
|
7
|
+
'pattern'
|
|
8
|
+
]).execute();
|
|
9
|
+
await db.schema.createIndex(`${ctx.tablePrefix}_grants_subject`).on('grants').column('subject').execute();
|
|
10
|
+
},
|
|
11
|
+
async down (db) {
|
|
12
|
+
await db.schema.dropTable('grants').execute();
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { AllowPredicate } from '@enkaku/server';
|
|
2
|
+
import { type HTTPPlugin } from '@sozai/http-server';
|
|
3
|
+
import { HOZON_DB } from '@teikyo/hozon';
|
|
4
|
+
import { type GrantStore } from './store.js';
|
|
5
|
+
export type AccessGrants = {
|
|
6
|
+
store: GrantStore;
|
|
7
|
+
/** Build an Enkaku access predicate admitting issuers, or verified delegates, holding a matching grant. */
|
|
8
|
+
allow(): AllowPredicate;
|
|
9
|
+
};
|
|
10
|
+
export declare const ACCESS_GRANTS: import("@sozai/http-server").PluginName<"access:grants", AccessGrants>;
|
|
11
|
+
export type AccessGrantsPluginParams = {
|
|
12
|
+
/** Maximum age of cached grants in milliseconds, a non-negative integer (0 disables caching). */
|
|
13
|
+
cacheTTLMs?: number;
|
|
14
|
+
};
|
|
15
|
+
/** Provide the grant store and an `allow()` predicate backed by a grant cache. */
|
|
16
|
+
export declare function accessGrantsPlugin(params?: AccessGrantsPluginParams): HTTPPlugin<'access:grants', AccessGrants, [typeof HOZON_DB]>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { pluginName } from '@sozai/http-server';
|
|
2
|
+
import { HOZON_DB } from '@teikyo/hozon';
|
|
3
|
+
import { GrantCache, isValidTTL } from './cache.js';
|
|
4
|
+
import { GRANT_STORE, grantStoreDefinition } from './store.js';
|
|
5
|
+
export const ACCESS_GRANTS = pluginName()('access:grants');
|
|
6
|
+
/** Provide the grant store and an `allow()` predicate backed by a grant cache. */ export function accessGrantsPlugin(params = {}) {
|
|
7
|
+
if (params.cacheTTLMs != null && !isValidTTL(params.cacheTTLMs)) {
|
|
8
|
+
throw new Error('Invalid cacheTTLMs');
|
|
9
|
+
}
|
|
10
|
+
return {
|
|
11
|
+
name: ACCESS_GRANTS,
|
|
12
|
+
dependsOn: [
|
|
13
|
+
HOZON_DB
|
|
14
|
+
],
|
|
15
|
+
async setup (ctx) {
|
|
16
|
+
const db = ctx.use(HOZON_DB);
|
|
17
|
+
db.register(grantStoreDefinition);
|
|
18
|
+
const store = await db.getStore(GRANT_STORE);
|
|
19
|
+
const cache = new GrantCache({
|
|
20
|
+
store,
|
|
21
|
+
ttlMs: params.cacheTTLMs
|
|
22
|
+
});
|
|
23
|
+
ctx.onClose(()=>cache.dispose());
|
|
24
|
+
return {
|
|
25
|
+
store,
|
|
26
|
+
allow: ()=>async ({ payload, procedure, verifyDelegation })=>{
|
|
27
|
+
try {
|
|
28
|
+
if (await cache.allows(payload.iss, procedure)) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
if (payload.sub != null && await cache.allows(payload.sub, procedure)) {
|
|
32
|
+
return await verifyDelegation();
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
} catch (cause) {
|
|
36
|
+
ctx.logger.error('access check failed for {procedure}', {
|
|
37
|
+
procedure,
|
|
38
|
+
error: cause
|
|
39
|
+
});
|
|
40
|
+
throw new Error('Access denied', {
|
|
41
|
+
cause
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Kysely, StoreDefinition } from '@hozon/db';
|
|
2
|
+
import type { GrantTables } from './tables.js';
|
|
3
|
+
export type Grant = {
|
|
4
|
+
subject: string;
|
|
5
|
+
pattern: string;
|
|
6
|
+
expiresAt: number | null;
|
|
7
|
+
createdAt: number;
|
|
8
|
+
createdBy: string;
|
|
9
|
+
};
|
|
10
|
+
export type GrantParams = {
|
|
11
|
+
subject: string;
|
|
12
|
+
pattern: string;
|
|
13
|
+
createdBy: string;
|
|
14
|
+
expiresAt?: number;
|
|
15
|
+
};
|
|
16
|
+
export type RevokeParams = {
|
|
17
|
+
subject: string;
|
|
18
|
+
pattern?: string;
|
|
19
|
+
};
|
|
20
|
+
export type GrantStore = {
|
|
21
|
+
/**
|
|
22
|
+
* Add a grant, or replace the expiry and audit fields of an existing grant for the same subject
|
|
23
|
+
* and pattern -- the latest grant wins. Throws inside a database transaction, because listeners
|
|
24
|
+
* could not be notified after the commit.
|
|
25
|
+
*/
|
|
26
|
+
grant(params: GrantParams): Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* Remove one grant, or all grants of the subject when no pattern is given. Returns the rows
|
|
29
|
+
* removed. Throws inside a database transaction.
|
|
30
|
+
*/
|
|
31
|
+
revoke(params: RevokeParams): Promise<number>;
|
|
32
|
+
list(params?: {
|
|
33
|
+
subject?: string;
|
|
34
|
+
}): Promise<Array<Grant>>;
|
|
35
|
+
/** Grants of the subject that have not expired at the given time. */
|
|
36
|
+
activeGrants(subject: string, now: number): Promise<Array<Grant>>;
|
|
37
|
+
/** Register a listener called after each committed mutation. Returns an unsubscribe function. */
|
|
38
|
+
onChange(listener: (subject: string) => void): () => void;
|
|
39
|
+
};
|
|
40
|
+
export declare function createGrantStoreAPI(db: Kysely<GrantTables>): GrantStore;
|
|
41
|
+
export declare const GRANT_STORE = "teikyo-grants";
|
|
42
|
+
export declare const grantStoreDefinition: StoreDefinition<GrantTables, GrantStore>;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { assertValidPattern } from '@kokuin/capability';
|
|
2
|
+
import { normalizeDID } from '@kokuin/token';
|
|
3
|
+
import { getLogger } from '@sozai/log';
|
|
4
|
+
import { grantStoreMigrations } from './migrations.js';
|
|
5
|
+
function toGrant(row) {
|
|
6
|
+
return {
|
|
7
|
+
subject: row.subject,
|
|
8
|
+
pattern: row.pattern,
|
|
9
|
+
expiresAt: row.expires_at == null ? null : Number(row.expires_at),
|
|
10
|
+
createdAt: Number(row.created_at),
|
|
11
|
+
createdBy: row.created_by
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
const TRANSACTION_ERROR = 'Grant mutations cannot run inside a transaction';
|
|
15
|
+
const logger = getLogger([
|
|
16
|
+
'teikyo',
|
|
17
|
+
'access',
|
|
18
|
+
'grants'
|
|
19
|
+
]);
|
|
20
|
+
export function createGrantStoreAPI(db) {
|
|
21
|
+
const listeners = new Set();
|
|
22
|
+
function assertTopLevel() {
|
|
23
|
+
if (db.isTransaction) {
|
|
24
|
+
throw new Error(TRANSACTION_ERROR);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function notify(subject) {
|
|
28
|
+
for (const listener of listeners){
|
|
29
|
+
try {
|
|
30
|
+
listener(subject);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
// A failing listener must not affect the others or the mutation result.
|
|
33
|
+
logger.error('grant change listener failed for {subject}', {
|
|
34
|
+
subject,
|
|
35
|
+
error
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
async grant (params) {
|
|
42
|
+
assertTopLevel();
|
|
43
|
+
assertValidPattern(params.pattern);
|
|
44
|
+
if (params.expiresAt !== undefined && !Number.isSafeInteger(params.expiresAt)) {
|
|
45
|
+
throw new Error('Invalid grant expiry');
|
|
46
|
+
}
|
|
47
|
+
const subject = normalizeDID(params.subject);
|
|
48
|
+
const expiresAt = params.expiresAt ?? null;
|
|
49
|
+
const createdAt = Date.now();
|
|
50
|
+
const createdBy = normalizeDID(params.createdBy);
|
|
51
|
+
await db.insertInto('grants').values({
|
|
52
|
+
subject,
|
|
53
|
+
pattern: params.pattern,
|
|
54
|
+
expires_at: expiresAt,
|
|
55
|
+
created_at: createdAt,
|
|
56
|
+
created_by: createdBy
|
|
57
|
+
}).onConflict((conflict)=>conflict.columns([
|
|
58
|
+
'subject',
|
|
59
|
+
'pattern'
|
|
60
|
+
]).doUpdateSet({
|
|
61
|
+
expires_at: expiresAt,
|
|
62
|
+
created_at: createdAt,
|
|
63
|
+
created_by: createdBy
|
|
64
|
+
})).execute();
|
|
65
|
+
notify(subject);
|
|
66
|
+
},
|
|
67
|
+
async revoke (params) {
|
|
68
|
+
assertTopLevel();
|
|
69
|
+
const subject = normalizeDID(params.subject);
|
|
70
|
+
let query = db.deleteFrom('grants').where('subject', '=', subject);
|
|
71
|
+
if (params.pattern != null) {
|
|
72
|
+
query = query.where('pattern', '=', params.pattern);
|
|
73
|
+
}
|
|
74
|
+
const result = await query.executeTakeFirst();
|
|
75
|
+
notify(subject);
|
|
76
|
+
return Number(result.numDeletedRows);
|
|
77
|
+
},
|
|
78
|
+
async list (params) {
|
|
79
|
+
let query = db.selectFrom('grants').selectAll().orderBy('created_at').orderBy('pattern');
|
|
80
|
+
if (params?.subject != null) {
|
|
81
|
+
query = query.where('subject', '=', normalizeDID(params.subject));
|
|
82
|
+
}
|
|
83
|
+
return (await query.execute()).map(toGrant);
|
|
84
|
+
},
|
|
85
|
+
async activeGrants (subject, now) {
|
|
86
|
+
const rows = await db.selectFrom('grants').selectAll().where('subject', '=', normalizeDID(subject)).where((eb)=>eb.or([
|
|
87
|
+
eb('expires_at', 'is', null),
|
|
88
|
+
eb('expires_at', '>', now)
|
|
89
|
+
])).execute();
|
|
90
|
+
return rows.map(toGrant);
|
|
91
|
+
},
|
|
92
|
+
onChange (listener) {
|
|
93
|
+
listeners.add(listener);
|
|
94
|
+
return ()=>{
|
|
95
|
+
listeners.delete(listener);
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
export const GRANT_STORE = 'teikyo-grants';
|
|
101
|
+
export const grantStoreDefinition = {
|
|
102
|
+
name: GRANT_STORE,
|
|
103
|
+
migrations: grantStoreMigrations,
|
|
104
|
+
createAPI: createGrantStoreAPI
|
|
105
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { GrantCache, type GrantCacheParams } from './grants/cache.js';
|
|
2
|
+
export { ACCESS_GRANTS, type AccessGrants, type AccessGrantsPluginParams, accessGrantsPlugin, } from './grants/plugin.js';
|
|
3
|
+
export { GRANT_STORE, type Grant, type GrantParams, type GrantStore, grantStoreDefinition, type RevokeParams, } from './grants/store.js';
|
|
4
|
+
export type { GrantTables } from './grants/tables.js';
|
|
5
|
+
export { patternMatches, patternsOverlap } from './patterns.js';
|
|
6
|
+
export { createRevocationHook, createRevocationStoreDefinition, REVOCATION_STORE, type RevocationHookParams, type RevocationStoreAPI, type RevocationStoreParams, } from './revocation/backend.js';
|
|
7
|
+
export { ACCESS_REVOCATION, type AccessRevocation, type AccessRevocationPluginParams, accessRevocationPlugin, } from './revocation/plugin.js';
|
|
8
|
+
export type { RevocationTables } from './revocation/tables.js';
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { GrantCache } from './grants/cache.js';
|
|
2
|
+
export { ACCESS_GRANTS, accessGrantsPlugin } from './grants/plugin.js';
|
|
3
|
+
export { GRANT_STORE, grantStoreDefinition } from './grants/store.js';
|
|
4
|
+
export { patternMatches, patternsOverlap } from './patterns.js';
|
|
5
|
+
export { createRevocationHook, createRevocationStoreDefinition, REVOCATION_STORE } from './revocation/backend.js';
|
|
6
|
+
export { ACCESS_REVOCATION, accessRevocationPlugin } from './revocation/plugin.js';
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Whether an access pattern covers a procedure, using the matcher Enkaku applies to access rules. */
|
|
2
|
+
export declare function patternMatches(pattern: string, procedure: string): boolean;
|
|
3
|
+
/** Whether two access patterns can both cover at least one procedure. */
|
|
4
|
+
export declare function patternsOverlap(a: string, b: string): boolean;
|
package/lib/patterns.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { hasPartsMatch } from '@kokuin/capability';
|
|
2
|
+
/** Whether an access pattern covers a procedure, using the matcher Enkaku applies to access rules. */ export function patternMatches(pattern, procedure) {
|
|
3
|
+
return hasPartsMatch(procedure, pattern);
|
|
4
|
+
}
|
|
5
|
+
function baseOf(pattern) {
|
|
6
|
+
return pattern.endsWith('/*') ? pattern.slice(0, -2) : pattern;
|
|
7
|
+
}
|
|
8
|
+
/** Whether two access patterns can both cover at least one procedure. */ export function patternsOverlap(a, b) {
|
|
9
|
+
if (a === '*' || b === '*') return true;
|
|
10
|
+
return patternMatches(a, baseOf(b)) || patternMatches(b, baseOf(a));
|
|
11
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { StoreDefinition } from '@hozon/db';
|
|
2
|
+
import { type RevocationBackend, type RevocationOptions, type VerifyTokenHook } from '@kokuin/capability';
|
|
3
|
+
import type { RevocationTables } from './tables.js';
|
|
4
|
+
/**
|
|
5
|
+
* Revocation backend of the store. `get` is scoped by issuer because the jti namespace belongs to
|
|
6
|
+
* each issuer: any identity can sign a valid revocation record for any jti, so a lookup by jti
|
|
7
|
+
* alone could return another issuer's record.
|
|
8
|
+
*/
|
|
9
|
+
export type RevocationStoreAPI = RevocationBackend;
|
|
10
|
+
export type RevocationStoreParams = RevocationOptions;
|
|
11
|
+
export declare const REVOCATION_STORE = "teikyo-revocations";
|
|
12
|
+
/** Store definition carrying the resolution options `add` needs; register it through the plugin. */
|
|
13
|
+
export declare function createRevocationStoreDefinition(options?: RevocationStoreParams): StoreDefinition<RevocationTables, RevocationStoreAPI>;
|
|
14
|
+
export type RevocationHookParams = {
|
|
15
|
+
backend: RevocationStoreAPI;
|
|
16
|
+
/** Reject capabilities that carry no jti, which could never be revoked. */
|
|
17
|
+
requireJTI: boolean;
|
|
18
|
+
/** Called with the underlying cause of an infrastructure failure, before it is masked. */
|
|
19
|
+
onError: (cause: unknown) => void;
|
|
20
|
+
options?: RevocationOptions;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Build the Enkaku `verifyToken` hook. Lookups are bound to the token's issuer. Failures other than
|
|
24
|
+
* a revocation are logged through `onError` and surface as `Access denied`, because Enkaku copies
|
|
25
|
+
* error messages to the client.
|
|
26
|
+
*/
|
|
27
|
+
export declare function createRevocationHook(params: RevocationHookParams): VerifyTokenHook;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createRevocationChecker } from '@kokuin/capability';
|
|
2
|
+
import { normalizeDID, verifyToken } from '@kokuin/token';
|
|
3
|
+
import { b64uToJSON } from '@sozai/codec';
|
|
4
|
+
import { revocationStoreMigrations } from './migrations.js';
|
|
5
|
+
export const REVOCATION_STORE = 'teikyo-revocations';
|
|
6
|
+
function toRecord(row) {
|
|
7
|
+
// The signed data is authoritative: header and payload are rebuilt from it, not stored separately.
|
|
8
|
+
const [header = '', payload = ''] = row.data.split('.');
|
|
9
|
+
return {
|
|
10
|
+
header: b64uToJSON(header),
|
|
11
|
+
payload: b64uToJSON(payload),
|
|
12
|
+
signature: row.signature,
|
|
13
|
+
data: row.data
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Create the revocation store API. `add` verifies the record's signature before storing it, like
|
|
18
|
+
* the in-memory backend of `@kokuin/capability`. It has no listeners to notify, so unlike grant
|
|
19
|
+
* mutations it is safe inside a database transaction: the row commits or rolls back with it.
|
|
20
|
+
*/ function createRevocationStoreAPI(options = {}) {
|
|
21
|
+
return (db)=>{
|
|
22
|
+
return {
|
|
23
|
+
async add (record) {
|
|
24
|
+
const verified = await verifyToken(record, {
|
|
25
|
+
historic: true,
|
|
26
|
+
resolver: options.resolver,
|
|
27
|
+
cache: options.cache,
|
|
28
|
+
methods: options.methods
|
|
29
|
+
});
|
|
30
|
+
if (verified.payload.rev !== true || typeof verified.payload.jti !== 'string') {
|
|
31
|
+
throw new Error('Invalid revocation record');
|
|
32
|
+
}
|
|
33
|
+
await db.insertInto('revocations').values({
|
|
34
|
+
issuer: normalizeDID(verified.payload.iss),
|
|
35
|
+
jti: verified.payload.jti,
|
|
36
|
+
// Only the signed material is stored, never the caller's whole object.
|
|
37
|
+
data: verified.data,
|
|
38
|
+
signature: verified.signature,
|
|
39
|
+
created_at: Date.now()
|
|
40
|
+
})// The first record per issuer wins: another record for the same issuer and jti is the
|
|
41
|
+
// same revocation, so this is idempotent.
|
|
42
|
+
.onConflict((conflict)=>conflict.columns([
|
|
43
|
+
'issuer',
|
|
44
|
+
'jti'
|
|
45
|
+
]).doNothing()).execute();
|
|
46
|
+
},
|
|
47
|
+
async get (jti, issuer) {
|
|
48
|
+
const row = await db.selectFrom('revocations').select([
|
|
49
|
+
'data',
|
|
50
|
+
'signature'
|
|
51
|
+
]).where('issuer', '=', normalizeDID(issuer)).where('jti', '=', jti).executeTakeFirst();
|
|
52
|
+
return row == null ? undefined : toRecord(row);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** Store definition carrying the resolution options `add` needs; register it through the plugin. */ export function createRevocationStoreDefinition(options) {
|
|
58
|
+
return {
|
|
59
|
+
name: REVOCATION_STORE,
|
|
60
|
+
migrations: revocationStoreMigrations,
|
|
61
|
+
createAPI: createRevocationStoreAPI(options)
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Build the Enkaku `verifyToken` hook. Lookups are bound to the token's issuer. Failures other than
|
|
66
|
+
* a revocation are logged through `onError` and surface as `Access denied`, because Enkaku copies
|
|
67
|
+
* error messages to the client.
|
|
68
|
+
*/ export function createRevocationHook(params) {
|
|
69
|
+
return async (token, raw)=>{
|
|
70
|
+
if (params.requireJTI && token.payload.jti == null) {
|
|
71
|
+
throw new Error('Capability without jti rejected');
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
await createRevocationChecker(params.backend, params.options)(token, raw);
|
|
75
|
+
} catch (cause) {
|
|
76
|
+
if (cause instanceof Error && cause.message.startsWith('Token revoked:')) {
|
|
77
|
+
throw cause;
|
|
78
|
+
}
|
|
79
|
+
params.onError(cause);
|
|
80
|
+
throw new Error('Access denied', {
|
|
81
|
+
cause
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function revocationStoreMigrations(ctx) {
|
|
2
|
+
return {
|
|
3
|
+
'0-init': {
|
|
4
|
+
async up (db) {
|
|
5
|
+
await db.schema.createTable('revocations').addColumn('issuer', ctx.types.text, (column)=>column.notNull()).addColumn('jti', ctx.types.text, (column)=>column.notNull()).addColumn('data', ctx.types.text, (column)=>column.notNull()).addColumn('signature', ctx.types.text, (column)=>column.notNull()).addColumn('created_at', ctx.types.bigint, (column)=>column.notNull()).addPrimaryKeyConstraint(`${ctx.tablePrefix}_revocations_pkey`, [
|
|
6
|
+
'issuer',
|
|
7
|
+
'jti'
|
|
8
|
+
]).execute();
|
|
9
|
+
},
|
|
10
|
+
async down (db) {
|
|
11
|
+
await db.schema.dropTable('revocations').execute();
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { RevocationOptions, VerifyTokenHook } from '@kokuin/capability';
|
|
2
|
+
import { type HTTPPlugin } from '@sozai/http-server';
|
|
3
|
+
import { HOZON_DB } from '@teikyo/hozon';
|
|
4
|
+
import { type RevocationStoreAPI } from './backend.js';
|
|
5
|
+
export type AccessRevocation = {
|
|
6
|
+
backend: RevocationStoreAPI;
|
|
7
|
+
/** Enkaku `verifyToken` hook rejecting revoked capabilities. */
|
|
8
|
+
verifyToken: VerifyTokenHook;
|
|
9
|
+
};
|
|
10
|
+
export declare const ACCESS_REVOCATION: import("@sozai/http-server").PluginName<"access:revocation", AccessRevocation>;
|
|
11
|
+
export type AccessRevocationPluginParams = RevocationOptions & {
|
|
12
|
+
/** Reject capabilities without a jti, which cannot be revoked. Defaults to `true`. */
|
|
13
|
+
requireJTI?: boolean;
|
|
14
|
+
};
|
|
15
|
+
/** Provide a persistent revocation backend and the `verifyToken` hook that consults it. */
|
|
16
|
+
export declare function accessRevocationPlugin(params?: AccessRevocationPluginParams): HTTPPlugin<'access:revocation', AccessRevocation, [typeof HOZON_DB]>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { pluginName } from '@sozai/http-server';
|
|
2
|
+
import { HOZON_DB } from '@teikyo/hozon';
|
|
3
|
+
import { createRevocationHook, createRevocationStoreDefinition, REVOCATION_STORE } from './backend.js';
|
|
4
|
+
export const ACCESS_REVOCATION = pluginName()('access:revocation');
|
|
5
|
+
/** Provide a persistent revocation backend and the `verifyToken` hook that consults it. */ export function accessRevocationPlugin(params = {}) {
|
|
6
|
+
const { requireJTI = true, ...options } = params;
|
|
7
|
+
return {
|
|
8
|
+
name: ACCESS_REVOCATION,
|
|
9
|
+
dependsOn: [
|
|
10
|
+
HOZON_DB
|
|
11
|
+
],
|
|
12
|
+
async setup (ctx) {
|
|
13
|
+
const db = ctx.use(HOZON_DB);
|
|
14
|
+
db.register(createRevocationStoreDefinition(options));
|
|
15
|
+
const backend = await db.getStore(REVOCATION_STORE);
|
|
16
|
+
return {
|
|
17
|
+
backend,
|
|
18
|
+
verifyToken: createRevocationHook({
|
|
19
|
+
backend,
|
|
20
|
+
requireJTI,
|
|
21
|
+
options,
|
|
22
|
+
onError: (cause)=>{
|
|
23
|
+
ctx.logger.error('revocation lookup failed', {
|
|
24
|
+
error: cause
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type RevocationTables = {
|
|
2
|
+
revocations: {
|
|
3
|
+
/** Normalised DID of the record's issuer. */
|
|
4
|
+
issuer: string;
|
|
5
|
+
jti: string;
|
|
6
|
+
/** The signed `header.payload` string of the record. */
|
|
7
|
+
data: string;
|
|
8
|
+
signature: string;
|
|
9
|
+
created_at: number;
|
|
10
|
+
};
|
|
11
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@teikyo/access",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Access grants and revocation for Enkaku servers",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/TairuFramework/teikyo.git",
|
|
8
|
+
"directory": "packages/access"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"sideEffects": false,
|
|
12
|
+
"type": "module",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./lib/index.js"
|
|
15
|
+
},
|
|
16
|
+
"main": "lib/index.js",
|
|
17
|
+
"types": "lib/index.d.ts",
|
|
18
|
+
"files": [
|
|
19
|
+
"lib/*"
|
|
20
|
+
],
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@enkaku/server": "^0.21.4",
|
|
23
|
+
"@hozon/db": "^0.2.1",
|
|
24
|
+
"@kokuin/capability": "^0.4.0",
|
|
25
|
+
"@kokuin/token": "^0.5.0",
|
|
26
|
+
"@sozai/codec": "^0.4.2",
|
|
27
|
+
"@sozai/http-server": "^0.1.0",
|
|
28
|
+
"@sozai/log": "^0.4.0",
|
|
29
|
+
"@teikyo/hozon": "^0.1.1"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@hozon/provider": "^0.1.1",
|
|
33
|
+
"@types/node": "^26.6.4",
|
|
34
|
+
"del-cli": "^7.0.0",
|
|
35
|
+
"vitest": "5.0.3"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=24"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
|
|
45
|
+
"build:clean": "del-cli lib",
|
|
46
|
+
"build:js": "del-cli \"lib/**/*.js\" && swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
|
|
47
|
+
"build:types": "del-cli \"lib/**/*.d.ts\" \"lib/**/*.d.ts.map\" && tsc --emitDeclarationOnly --skipLibCheck",
|
|
48
|
+
"build:types:ci": "tsc --emitDeclarationOnly --skipLibCheck --declarationMap false",
|
|
49
|
+
"test": "pnpm run test:types && pnpm run test:unit",
|
|
50
|
+
"test:types": "tsc --noEmit --skipLibCheck -p tsconfig.test.json",
|
|
51
|
+
"test:unit": "vitest run"
|
|
52
|
+
}
|
|
6
53
|
}
|