@teikyo/access 0.4.0 → 0.6.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 CHANGED
@@ -66,7 +66,12 @@ const cache = new GrantCache({ store })
66
66
  await cache.allows('did:key:z6Mk...', 'app/read')
67
67
  ```
68
68
 
69
- - `GrantStore` upserts grants by `(subject, pattern)`, normalises subject DIDs and validates patterns.
69
+ - `GrantStore` upserts grants by `(created_by, subject, pattern)`, normalises DIDs and validates patterns.
70
+ - `grant` and `revoke` require `createdBy`, scope writes to that creator, and increment its set version.
71
+ - `replaceSet` atomically replaces one creator's grants using `expectedVersion`. Stale versions throw `GrantVersionConflictError` with `currentVersion`.
72
+ - `getSetVersion` returns zero before the first mutation. `replaceSet` can enforce `maxEntries`, throwing `GrantLimitError` without writes.
73
+ - `accessGrantsPlugin({ operators })` reconciles `config:operators` to one `admin/*` grant per configured DID during setup.
74
+ - `reconcileOperators(store, { dids, pattern? })` exposes the same reconciliation with an optional pattern.
70
75
  - `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
76
  - `patternMatches(pattern, procedure)` uses the matcher Enkaku applies to access rules; `patternsOverlap(a, b)` reports whether two patterns can cover a common procedure.
72
77
 
@@ -0,0 +1,9 @@
1
+ import type { ProcedureHandlers } from '@enkaku/server';
2
+ import type { AccessGrants } from '../grants/plugin.js';
3
+ import type { AccessAdminProtocol } from './protocol.js';
4
+ export type AccessAdminHandlersParams = {
5
+ grants: AccessGrants;
6
+ maxEntries?: number;
7
+ };
8
+ /** WARNING: mount behind Enkaku's `admin/*` grant check, because handlers rely on transport authorisation. */
9
+ export declare function createAccessAdminHandlers({ grants, maxEntries, }: AccessAdminHandlersParams): ProcedureHandlers<AccessAdminProtocol>;
@@ -0,0 +1,98 @@
1
+ import { HandlerError } from '@enkaku/server';
2
+ import { normalizeDID } from '@kokuin/token';
3
+ import { GrantLimitError, GrantVersionConflictError } from '../grants/store.js';
4
+ import { patternsOverlap } from '../patterns.js';
5
+ function issuer(payload) {
6
+ if (typeof payload.iss !== 'string') throw new HandlerError({
7
+ code: 'ACCESS_DENIED',
8
+ message: 'Verified caller required'
9
+ });
10
+ return payload.iss;
11
+ }
12
+ function assertGrantable(pattern) {
13
+ if (patternsOverlap(pattern, 'admin/*')) {
14
+ throw new HandlerError({
15
+ code: 'FORBIDDEN_PATTERN',
16
+ message: 'Admin patterns are configured by operators'
17
+ });
18
+ }
19
+ }
20
+ async function mutate(operation) {
21
+ try {
22
+ return await operation();
23
+ } catch (error) {
24
+ if (error instanceof GrantVersionConflictError) {
25
+ throw HandlerError.from(error, {
26
+ code: 'VERSION_CONFLICT',
27
+ data: {
28
+ currentVersion: error.currentVersion
29
+ },
30
+ message: error.message
31
+ });
32
+ }
33
+ if (error instanceof GrantLimitError) throw HandlerError.from(error, {
34
+ code: 'LIMIT',
35
+ message: error.message
36
+ });
37
+ throw error;
38
+ }
39
+ }
40
+ /** WARNING: mount behind Enkaku's `admin/*` grant check, because handlers rely on transport authorisation. */ export function createAccessAdminHandlers({ grants, maxEntries }) {
41
+ const store = grants.store;
42
+ return {
43
+ 'admin/grants/list': async ({ message, param })=>{
44
+ const createdBy = param.createdBy ?? issuer(message.payload);
45
+ return await store.listSet(createdBy);
46
+ },
47
+ 'admin/grants/set': async ({ message, param })=>{
48
+ for (const entry of param.entries)assertGrantable(entry.pattern);
49
+ return await mutate(()=>{
50
+ return store.replaceSet({
51
+ ...param,
52
+ createdBy: issuer(message.payload),
53
+ maxEntries
54
+ });
55
+ });
56
+ },
57
+ 'admin/grants/add': async ({ message, param })=>{
58
+ assertGrantable(param.pattern);
59
+ const createdBy = issuer(message.payload);
60
+ // Use replaceSet so limits and version checks remain atomic with the mutation.
61
+ const version = await store.getSetVersion(createdBy);
62
+ const entries = (await store.list({
63
+ createdBy
64
+ })).filter((entry)=>{
65
+ return entry.subject !== normalizeDID(param.subject) || entry.pattern !== param.pattern;
66
+ }).map((entry)=>{
67
+ return {
68
+ subject: entry.subject,
69
+ pattern: entry.pattern,
70
+ ...entry.expiresAt === null ? {} : {
71
+ expiresAt: entry.expiresAt
72
+ }
73
+ };
74
+ });
75
+ entries.push(param);
76
+ return await mutate(()=>{
77
+ return store.replaceSet({
78
+ createdBy,
79
+ entries,
80
+ expectedVersion: version,
81
+ maxEntries
82
+ });
83
+ });
84
+ },
85
+ 'admin/grants/remove': async ({ message, param })=>{
86
+ assertGrantable(param.pattern);
87
+ const createdBy = issuer(message.payload);
88
+ await store.revoke({
89
+ createdBy,
90
+ subject: param.subject,
91
+ pattern: param.pattern
92
+ });
93
+ return {
94
+ version: await store.getSetVersion(createdBy)
95
+ };
96
+ }
97
+ };
98
+ }
@@ -0,0 +1,151 @@
1
+ export declare const accessAdminProtocol: {
2
+ readonly 'admin/grants/list': {
3
+ readonly type: "request";
4
+ readonly param: {
5
+ readonly type: "object";
6
+ readonly properties: {
7
+ readonly createdBy: {
8
+ readonly type: "string";
9
+ };
10
+ };
11
+ readonly additionalProperties: false;
12
+ };
13
+ readonly result: {
14
+ readonly type: "object";
15
+ readonly properties: {
16
+ readonly version: {
17
+ readonly type: "integer";
18
+ };
19
+ readonly entries: {
20
+ readonly type: "array";
21
+ readonly items: {
22
+ readonly type: "object";
23
+ readonly properties: {
24
+ readonly expiresAt: {
25
+ readonly type: readonly ["integer", "null"];
26
+ };
27
+ readonly createdBy: {
28
+ readonly type: "string";
29
+ };
30
+ readonly createdAt: {
31
+ readonly type: "integer";
32
+ };
33
+ readonly subject: {
34
+ readonly type: "string";
35
+ };
36
+ readonly pattern: {
37
+ readonly type: "string";
38
+ };
39
+ };
40
+ readonly required: readonly ["subject", "pattern", "expiresAt", "createdBy", "createdAt"];
41
+ readonly additionalProperties: false;
42
+ };
43
+ };
44
+ };
45
+ readonly required: readonly ["entries", "version"];
46
+ readonly additionalProperties: false;
47
+ };
48
+ };
49
+ readonly 'admin/grants/set': {
50
+ readonly type: "request";
51
+ readonly param: {
52
+ readonly type: "object";
53
+ readonly properties: {
54
+ readonly entries: {
55
+ readonly type: "array";
56
+ readonly items: {
57
+ readonly type: "object";
58
+ readonly properties: {
59
+ readonly subject: {
60
+ readonly type: "string";
61
+ };
62
+ readonly pattern: {
63
+ readonly type: "string";
64
+ };
65
+ readonly expiresAt: {
66
+ readonly type: "integer";
67
+ };
68
+ };
69
+ readonly required: readonly ["subject", "pattern"];
70
+ readonly additionalProperties: false;
71
+ };
72
+ };
73
+ readonly expectedVersion: {
74
+ readonly type: "integer";
75
+ readonly minimum: 0;
76
+ };
77
+ };
78
+ readonly required: readonly ["entries", "expectedVersion"];
79
+ readonly additionalProperties: false;
80
+ };
81
+ readonly result: {
82
+ readonly type: "object";
83
+ readonly properties: {
84
+ readonly version: {
85
+ readonly type: "integer";
86
+ };
87
+ };
88
+ readonly required: readonly ["version"];
89
+ readonly additionalProperties: false;
90
+ };
91
+ };
92
+ readonly 'admin/grants/add': {
93
+ readonly type: "request";
94
+ readonly param: {
95
+ readonly type: "object";
96
+ readonly properties: {
97
+ readonly subject: {
98
+ readonly type: "string";
99
+ };
100
+ readonly pattern: {
101
+ readonly type: "string";
102
+ };
103
+ readonly expiresAt: {
104
+ readonly type: "integer";
105
+ };
106
+ };
107
+ readonly required: readonly ["subject", "pattern"];
108
+ readonly additionalProperties: false;
109
+ };
110
+ readonly result: {
111
+ readonly type: "object";
112
+ readonly properties: {
113
+ readonly version: {
114
+ readonly type: "integer";
115
+ };
116
+ };
117
+ readonly required: readonly ["version"];
118
+ readonly additionalProperties: false;
119
+ };
120
+ };
121
+ readonly 'admin/grants/remove': {
122
+ readonly type: "request";
123
+ readonly param: {
124
+ readonly type: "object";
125
+ readonly properties: {
126
+ readonly subject: {
127
+ readonly type: "string";
128
+ };
129
+ readonly pattern: {
130
+ readonly type: "string";
131
+ };
132
+ readonly expiresAt: {
133
+ readonly type: "integer";
134
+ };
135
+ };
136
+ readonly required: readonly ["subject", "pattern"];
137
+ readonly additionalProperties: false;
138
+ };
139
+ readonly result: {
140
+ readonly type: "object";
141
+ readonly properties: {
142
+ readonly version: {
143
+ readonly type: "integer";
144
+ };
145
+ };
146
+ readonly required: readonly ["version"];
147
+ readonly additionalProperties: false;
148
+ };
149
+ };
150
+ };
151
+ export type AccessAdminProtocol = typeof accessAdminProtocol;
@@ -0,0 +1,119 @@
1
+ const entry = {
2
+ type: 'object',
3
+ properties: {
4
+ subject: {
5
+ type: 'string'
6
+ },
7
+ pattern: {
8
+ type: 'string'
9
+ },
10
+ expiresAt: {
11
+ type: 'integer'
12
+ }
13
+ },
14
+ required: [
15
+ 'subject',
16
+ 'pattern'
17
+ ],
18
+ additionalProperties: false
19
+ };
20
+ const version = {
21
+ type: 'object',
22
+ properties: {
23
+ version: {
24
+ type: 'integer'
25
+ }
26
+ },
27
+ required: [
28
+ 'version'
29
+ ],
30
+ additionalProperties: false
31
+ };
32
+ export const accessAdminProtocol = {
33
+ 'admin/grants/list': {
34
+ type: 'request',
35
+ param: {
36
+ type: 'object',
37
+ properties: {
38
+ createdBy: {
39
+ type: 'string'
40
+ }
41
+ },
42
+ additionalProperties: false
43
+ },
44
+ result: {
45
+ type: 'object',
46
+ properties: {
47
+ version: {
48
+ type: 'integer'
49
+ },
50
+ entries: {
51
+ type: 'array',
52
+ items: {
53
+ type: 'object',
54
+ properties: {
55
+ ...entry.properties,
56
+ expiresAt: {
57
+ type: [
58
+ 'integer',
59
+ 'null'
60
+ ]
61
+ },
62
+ createdBy: {
63
+ type: 'string'
64
+ },
65
+ createdAt: {
66
+ type: 'integer'
67
+ }
68
+ },
69
+ required: [
70
+ 'subject',
71
+ 'pattern',
72
+ 'expiresAt',
73
+ 'createdBy',
74
+ 'createdAt'
75
+ ],
76
+ additionalProperties: false
77
+ }
78
+ }
79
+ },
80
+ required: [
81
+ 'entries',
82
+ 'version'
83
+ ],
84
+ additionalProperties: false
85
+ }
86
+ },
87
+ 'admin/grants/set': {
88
+ type: 'request',
89
+ param: {
90
+ type: 'object',
91
+ properties: {
92
+ entries: {
93
+ type: 'array',
94
+ items: entry
95
+ },
96
+ expectedVersion: {
97
+ type: 'integer',
98
+ minimum: 0
99
+ }
100
+ },
101
+ required: [
102
+ 'entries',
103
+ 'expectedVersion'
104
+ ],
105
+ additionalProperties: false
106
+ },
107
+ result: version
108
+ },
109
+ 'admin/grants/add': {
110
+ type: 'request',
111
+ param: entry,
112
+ result: version
113
+ },
114
+ 'admin/grants/remove': {
115
+ type: 'request',
116
+ param: entry,
117
+ result: version
118
+ }
119
+ };
@@ -1,5 +1,6 @@
1
1
  import { normalizeDID } from '@kokuin/token';
2
2
  import { patternMatches } from '../patterns.js';
3
+ import { OPERATORS_CREATOR_ID } from './operators.js';
3
4
  const DEFAULT_TTL_MS = 5000;
4
5
  const DEFAULT_MAX_ENTRIES = 10_000;
5
6
  /** Whether `ttlMs` is a usable cache lifetime: a non-negative safe integer (0 disables caching). */ export function isValidTTL(ttlMs) {
@@ -92,7 +93,9 @@ const DEFAULT_MAX_ENTRIES = 10_000;
92
93
  throw new Error('GrantCache is disposed');
93
94
  }
94
95
  const grants = await this.#grantsFor(normalizeDID(subject));
95
- return grants.some((grant)=>patternMatches(grant.pattern, procedure));
96
+ return grants.some((grant)=>{
97
+ return (!procedure.startsWith('admin/') || grant.createdBy === OPERATORS_CREATOR_ID) && patternMatches(grant.pattern, procedure);
98
+ });
96
99
  }
97
100
  /** Stop listening to the store and drop all cached state. */ dispose() {
98
101
  this.#disposed = true;
@@ -11,6 +11,32 @@ export function grantStoreMigrations(ctx) {
11
11
  async down (db) {
12
12
  await db.schema.dropTable('grants').execute();
13
13
  }
14
+ },
15
+ '1-owner-key': {
16
+ async up (db) {
17
+ await db.schema.createTable('grants_owned').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_owner_pkey`, [
18
+ 'created_by',
19
+ 'subject',
20
+ 'pattern'
21
+ ]).execute();
22
+ await db.insertInto('grants_owned').columns([
23
+ 'subject',
24
+ 'pattern',
25
+ 'expires_at',
26
+ 'created_at',
27
+ 'created_by'
28
+ ]).expression(db.selectFrom('grants').select([
29
+ 'subject',
30
+ 'pattern',
31
+ 'expires_at',
32
+ 'created_at',
33
+ 'created_by'
34
+ ])).execute();
35
+ await db.schema.dropTable('grants').execute();
36
+ await db.schema.alterTable('grants_owned').renameTo('grants').execute();
37
+ await db.schema.createIndex(`${ctx.tablePrefix}_grants_subject`).on('grants').column('subject').execute();
38
+ await db.schema.createTable('grant_sets').addColumn('created_by', ctx.types.text, (column)=>column.notNull().primaryKey()).addColumn('version', 'integer', (column)=>column.notNull()).execute();
39
+ }
14
40
  }
15
41
  };
16
42
  }
@@ -0,0 +1,6 @@
1
+ import type { GrantStore } from './store.js';
2
+ export declare const OPERATORS_CREATOR_ID = "config:operators";
3
+ export declare function reconcileOperators(store: GrantStore, params: {
4
+ dids: Array<string>;
5
+ pattern?: string;
6
+ }): Promise<void>;
@@ -0,0 +1,26 @@
1
+ import { normalizeDID } from '@kokuin/token';
2
+ import { GrantVersionConflictError } from './store.js';
3
+ export const OPERATORS_CREATOR_ID = 'config:operators';
4
+ export async function reconcileOperators(store, params) {
5
+ const entries = [
6
+ ...new Set(params.dids.map(normalizeDID))
7
+ ].map((subject)=>{
8
+ return {
9
+ subject,
10
+ pattern: params.pattern ?? 'admin/*'
11
+ };
12
+ });
13
+ for(let attempt = 0;; attempt++){
14
+ const expectedVersion = await store.getSetVersion(OPERATORS_CREATOR_ID);
15
+ try {
16
+ await store.replaceSet({
17
+ createdBy: OPERATORS_CREATOR_ID,
18
+ entries,
19
+ expectedVersion
20
+ });
21
+ return;
22
+ } catch (error) {
23
+ if (!(error instanceof GrantVersionConflictError) || attempt >= 4) throw error;
24
+ }
25
+ }
26
+ }
@@ -11,6 +11,7 @@ export type AccessGrants = {
11
11
  };
12
12
  export declare const ACCESS_GRANTS: import("@sozai/http-server").PluginName<"access:grants", AccessGrants>;
13
13
  export type AccessGrantsPluginParams = {
14
+ operators?: Array<string>;
14
15
  /** Maximum age of cached grants in milliseconds, a non-negative integer (0 disables caching). */
15
16
  cacheTTLMs?: number;
16
17
  };
@@ -1,6 +1,8 @@
1
+ import { normalizeDID } from '@kokuin/token';
1
2
  import { pluginName } from '@sozai/http-server';
2
3
  import { HOZON_DB } from '@teikyo/hozon';
3
4
  import { GrantCache, isValidTTL } from './cache.js';
5
+ import { reconcileOperators } from './operators.js';
4
6
  import { GRANT_STORE, grantStoreDefinition } from './store.js';
5
7
  export const ACCESS_GRANTS = pluginName()('access:grants');
6
8
  /** Provide the grant store and an `allow()` predicate backed by a grant cache. */ export function accessGrantsPlugin(params = {}) {
@@ -16,6 +18,9 @@ export const ACCESS_GRANTS = pluginName()('access:grants');
16
18
  const db = ctx.use(HOZON_DB);
17
19
  db.register(grantStoreDefinition);
18
20
  const store = await db.getStore(GRANT_STORE);
21
+ await reconcileOperators(store, {
22
+ dids: params.operators ?? []
23
+ });
19
24
  const cache = new GrantCache({
20
25
  store,
21
26
  ttlMs: params.cacheTTLMs
@@ -36,12 +41,13 @@ export const ACCESS_GRANTS = pluginName()('access:grants');
36
41
  });
37
42
  }
38
43
  },
39
- allow: ()=>async ({ payload, procedure, verifyDelegation })=>{
44
+ allow: ()=>{
45
+ return async ({ payload, procedure, verifyDelegation })=>{
40
46
  try {
41
- if (await cache.allows(payload.iss, procedure)) {
42
- return true;
47
+ if (payload.sub == null || normalizeDID(payload.sub) === normalizeDID(payload.iss)) {
48
+ return await cache.allows(payload.iss, procedure);
43
49
  }
44
- if (payload.sub != null && await cache.allows(payload.sub, procedure)) {
50
+ if (await cache.allows(payload.sub, procedure)) {
45
51
  return await verifyDelegation();
46
52
  }
47
53
  return false;
@@ -54,7 +60,8 @@ export const ACCESS_GRANTS = pluginName()('access:grants');
54
60
  cause
55
61
  });
56
62
  }
57
- }
63
+ };
64
+ }
58
65
  };
59
66
  }
60
67
  };
@@ -14,27 +14,49 @@ export type GrantParams = {
14
14
  expiresAt?: number;
15
15
  };
16
16
  export type RevokeParams = {
17
+ createdBy: string;
17
18
  subject: string;
18
19
  pattern?: string;
19
20
  };
21
+ export type ReplaceGrantSetParams = {
22
+ createdBy: string;
23
+ entries: Array<{
24
+ subject: string;
25
+ pattern: string;
26
+ expiresAt?: number;
27
+ }>;
28
+ expectedVersion: number;
29
+ maxEntries?: number;
30
+ };
31
+ export type GrantVersionConflictErrorParams = {
32
+ currentVersion: number;
33
+ };
34
+ export declare class GrantVersionConflictError extends Error {
35
+ currentVersion: number;
36
+ constructor(params: GrantVersionConflictErrorParams);
37
+ }
38
+ export declare class GrantLimitError extends Error {
39
+ constructor();
40
+ }
20
41
  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
- */
42
+ /** WARNING: use a top-level store for mutations, because nested transactions cannot commit independently. */
26
43
  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
44
  revoke(params: RevokeParams): Promise<number>;
45
+ replaceSet(params: ReplaceGrantSetParams): Promise<{
46
+ version: number;
47
+ }>;
48
+ getSetVersion(createdBy: string): Promise<number>;
49
+ /** Entries and version from one database snapshot, including expired grants. */
50
+ listSet(createdBy: string): Promise<{
51
+ entries: Array<Grant>;
52
+ version: number;
53
+ }>;
32
54
  list(params?: {
33
55
  subject?: string;
56
+ createdBy?: string;
34
57
  }): Promise<Array<Grant>>;
35
- /** Grants of the subject that have not expired at the given time. */
36
58
  activeGrants(subject: string, now: number): Promise<Array<Grant>>;
37
- /** Register a listener called after each committed mutation. Returns an unsubscribe function. */
59
+ /** Called after commit, once for each affected subject. */
38
60
  onChange(listener: (subject: string) => void): () => void;
39
61
  };
40
62
  export declare function createGrantStoreAPI(db: Kysely<GrantTables>): GrantStore;
@@ -1,7 +1,22 @@
1
+ import { withStoreTransaction } from '@hozon/db';
1
2
  import { assertValidPattern } from '@kokuin/capability';
2
3
  import { normalizeDID } from '@kokuin/token';
3
4
  import { getLogger } from '@sozai/log';
4
5
  import { grantStoreMigrations } from './migrations.js';
6
+ export class GrantVersionConflictError extends Error {
7
+ currentVersion;
8
+ constructor(params){
9
+ super('Grant set version conflict');
10
+ this.name = 'GrantVersionConflictError';
11
+ this.currentVersion = params.currentVersion;
12
+ }
13
+ }
14
+ export class GrantLimitError extends Error {
15
+ constructor(){
16
+ super('Grant set limit exceeded');
17
+ this.name = 'GrantLimitError';
18
+ }
19
+ }
5
20
  function toGrant(row) {
6
21
  return {
7
22
  subject: row.subject,
@@ -37,49 +52,132 @@ export function createGrantStoreAPI(db) {
37
52
  }
38
53
  }
39
54
  }
55
+ async function lockVersion(tx, createdBy) {
56
+ await tx.insertInto('grant_sets').values({
57
+ created_by: createdBy,
58
+ version: 0
59
+ }).onConflict((conflict)=>conflict.column('created_by').doNothing()).execute();
60
+ // Reserve SQLite's writer and lock the Postgres row before reading the version.
61
+ const row = await tx.updateTable('grant_sets').set((eb)=>({
62
+ version: eb.ref('version')
63
+ })).where('created_by', '=', createdBy).returning('version').executeTakeFirstOrThrow();
64
+ return Number(row.version);
65
+ }
66
+ async function bumpVersion(tx, createdBy, version) {
67
+ await tx.updateTable('grant_sets').set({
68
+ version: version + 1
69
+ }).where('created_by', '=', createdBy).execute();
70
+ }
71
+ function grantRow(params) {
72
+ assertValidPattern(params.pattern);
73
+ if (params.expiresAt !== undefined && !Number.isSafeInteger(params.expiresAt)) {
74
+ throw new Error('Invalid grant expiry');
75
+ }
76
+ return {
77
+ subject: normalizeDID(params.subject),
78
+ pattern: params.pattern,
79
+ expires_at: params.expiresAt ?? null,
80
+ created_at: Date.now(),
81
+ created_by: normalizeDID(params.createdBy)
82
+ };
83
+ }
40
84
  return {
41
85
  async grant (params) {
42
86
  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);
87
+ const row = grantRow(params);
88
+ await withStoreTransaction(db, async (tx)=>{
89
+ const version = await lockVersion(tx, row.created_by);
90
+ await tx.insertInto('grants').values(row).onConflict((conflict)=>{
91
+ return conflict.columns([
92
+ 'created_by',
93
+ 'subject',
94
+ 'pattern'
95
+ ]).doUpdateSet({
96
+ expires_at: row.expires_at,
97
+ created_at: row.created_at
98
+ });
99
+ }).execute();
100
+ await bumpVersion(tx, row.created_by, version);
101
+ });
102
+ notify(row.subject);
66
103
  },
67
104
  async revoke (params) {
68
105
  assertTopLevel();
69
106
  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);
107
+ const createdBy = normalizeDID(params.createdBy);
108
+ const removed = await withStoreTransaction(db, async (tx)=>{
109
+ const version = await lockVersion(tx, createdBy);
110
+ let query = tx.deleteFrom('grants').where('subject', '=', subject).where('created_by', '=', createdBy);
111
+ if (params.pattern != null) query = query.where('pattern', '=', params.pattern);
112
+ const result = await query.executeTakeFirst();
113
+ await bumpVersion(tx, createdBy, version);
114
+ return Number(result.numDeletedRows);
115
+ });
116
+ if (removed > 0) notify(subject);
117
+ return removed;
118
+ },
119
+ async replaceSet (params) {
120
+ assertTopLevel();
121
+ const createdBy = normalizeDID(params.createdBy);
122
+ const result = await withStoreTransaction(db, async (tx)=>{
123
+ const version = await lockVersion(tx, createdBy);
124
+ if (version !== params.expectedVersion) throw new GrantVersionConflictError({
125
+ currentVersion: version
126
+ });
127
+ if (params.maxEntries !== undefined && params.entries.length > params.maxEntries) throw new GrantLimitError();
128
+ const rows = params.entries.map((entry)=>grantRow({
129
+ ...entry,
130
+ createdBy
131
+ }));
132
+ const previous = await tx.selectFrom('grants').select('subject').where('created_by', '=', createdBy).execute();
133
+ await tx.deleteFrom('grants').where('created_by', '=', createdBy).execute();
134
+ for (const row of rows)await tx.insertInto('grants').values(row).execute();
135
+ await bumpVersion(tx, createdBy, version);
136
+ return {
137
+ version: version + 1,
138
+ subjects: new Set([
139
+ ...previous,
140
+ ...rows
141
+ ].map((row)=>row.subject))
142
+ };
143
+ });
144
+ for (const subject of result.subjects)notify(subject);
145
+ return {
146
+ version: result.version
147
+ };
148
+ },
149
+ async getSetVersion (createdBy) {
150
+ const row = await db.selectFrom('grant_sets').select('version').where('created_by', '=', normalizeDID(createdBy)).executeTakeFirst();
151
+ return row == null ? 0 : Number(row.version);
152
+ },
153
+ async listSet (createdBy) {
154
+ // One statement keeps entries and version consistent even under READ COMMITTED.
155
+ const rows = await db.selectFrom(db.selectNoFrom((eb)=>eb.val(normalizeDID(createdBy)).as('created_by')).as('creator')).leftJoin('grant_sets', 'grant_sets.created_by', 'creator.created_by').leftJoin('grants', 'grants.created_by', 'creator.created_by').selectAll('grants').select('grant_sets.version').orderBy('grants.created_at').orderBy('grants.pattern').execute();
156
+ const entries = [];
157
+ for (const row of rows){
158
+ if (row.subject !== null && row.pattern !== null && row.created_at !== null && row.created_by !== null) {
159
+ entries.push(toGrant({
160
+ subject: row.subject,
161
+ pattern: row.pattern,
162
+ expires_at: row.expires_at,
163
+ created_at: row.created_at,
164
+ created_by: row.created_by
165
+ }));
166
+ }
73
167
  }
74
- const result = await query.executeTakeFirst();
75
- notify(subject);
76
- return Number(result.numDeletedRows);
168
+ return {
169
+ entries,
170
+ version: Number(rows[0]?.version ?? 0)
171
+ };
77
172
  },
78
173
  async list (params) {
79
174
  let query = db.selectFrom('grants').selectAll().orderBy('created_at').orderBy('pattern');
80
175
  if (params?.subject != null) {
81
176
  query = query.where('subject', '=', normalizeDID(params.subject));
82
177
  }
178
+ if (params?.createdBy != null) {
179
+ query = query.where('created_by', '=', normalizeDID(params.createdBy));
180
+ }
83
181
  return (await query.execute()).map(toGrant);
84
182
  },
85
183
  async activeGrants (subject, now) {
@@ -1,4 +1,8 @@
1
1
  export type GrantTables = {
2
+ grant_sets: {
3
+ created_by: string;
4
+ version: number;
5
+ };
2
6
  grants: {
3
7
  subject: string;
4
8
  pattern: string;
package/lib/index.d.ts CHANGED
@@ -1,8 +1,13 @@
1
+ export { type AccessAdminHandlersParams, createAccessAdminHandlers } from './admin/handlers.js';
2
+ export { type AccessAdminProtocol, accessAdminProtocol } from './admin/protocol.js';
1
3
  export { GrantCache, type GrantCacheParams } from './grants/cache.js';
4
+ export { OPERATORS_CREATOR_ID, reconcileOperators } from './grants/operators.js';
2
5
  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';
6
+ export { GRANT_STORE, type Grant, GrantLimitError, type GrantParams, type GrantStore, GrantVersionConflictError, type GrantVersionConflictErrorParams, grantStoreDefinition, type ReplaceGrantSetParams, type RevokeParams, } from './grants/store.js';
4
7
  export type { GrantTables } from './grants/tables.js';
5
8
  export { patternMatches, patternsOverlap } from './patterns.js';
9
+ export { ACCESS_REPLAY, type AccessReplay, type AccessReplayPluginParams, accessReplayPlugin, } from './replay/plugin.js';
10
+ export { REPLAY_STORE, type ReplayStore, type ReplayTables, replayStoreDefinition, } from './replay/store.js';
6
11
  export { createRevocationHook, createRevocationStoreDefinition, REVOCATION_STORE, type RevocationHookParams, type RevocationStoreAPI, type RevocationStoreParams, } from './revocation/backend.js';
7
12
  export { ACCESS_REVOCATION, type AccessRevocation, type AccessRevocationPluginParams, accessRevocationPlugin, } from './revocation/plugin.js';
8
13
  export type { RevocationTables } from './revocation/tables.js';
package/lib/index.js CHANGED
@@ -1,6 +1,11 @@
1
+ export { createAccessAdminHandlers } from './admin/handlers.js';
2
+ export { accessAdminProtocol } from './admin/protocol.js';
1
3
  export { GrantCache } from './grants/cache.js';
4
+ export { OPERATORS_CREATOR_ID, reconcileOperators } from './grants/operators.js';
2
5
  export { ACCESS_GRANTS, accessGrantsPlugin } from './grants/plugin.js';
3
- export { GRANT_STORE, grantStoreDefinition } from './grants/store.js';
6
+ export { GRANT_STORE, GrantLimitError, GrantVersionConflictError, grantStoreDefinition } from './grants/store.js';
4
7
  export { patternMatches, patternsOverlap } from './patterns.js';
8
+ export { ACCESS_REPLAY, accessReplayPlugin } from './replay/plugin.js';
9
+ export { REPLAY_STORE, replayStoreDefinition } from './replay/store.js';
5
10
  export { createRevocationHook, createRevocationStoreDefinition, REVOCATION_STORE } from './revocation/backend.js';
6
11
  export { ACCESS_REVOCATION, accessRevocationPlugin } from './revocation/plugin.js';
package/lib/patterns.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { hasPartsMatch } from '@kokuin/capability';
2
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);
3
+ return hasPartsMatch({
4
+ expected: procedure,
5
+ actual: pattern
6
+ });
4
7
  }
5
8
  function baseOf(pattern) {
6
9
  return pattern.endsWith('/*') ? pattern.slice(0, -2) : pattern;
@@ -0,0 +1,11 @@
1
+ import type { ReplayCache } from '@enkaku/server';
2
+ import type { HTTPPlugin } from '@sozai/http-server';
3
+ import { HOZON_DB } from '@teikyo/hozon';
4
+ export type AccessReplay = {
5
+ cache: ReplayCache;
6
+ };
7
+ export declare const ACCESS_REPLAY: import("@sozai/http-server").PluginName<"access:replay", AccessReplay>;
8
+ export type AccessReplayPluginParams = {
9
+ cleanupIntervalMs?: number;
10
+ };
11
+ export declare function accessReplayPlugin(params?: AccessReplayPluginParams): HTTPPlugin<'access:replay', AccessReplay, [typeof HOZON_DB]>;
@@ -0,0 +1,28 @@
1
+ import { pluginName } from '@sozai/http-server';
2
+ import { HOZON_DB } from '@teikyo/hozon';
3
+ import { REPLAY_STORE, replayStoreDefinition } from './store.js';
4
+ export const ACCESS_REPLAY = pluginName()('access:replay');
5
+ export function accessReplayPlugin(params = {}) {
6
+ return {
7
+ name: ACCESS_REPLAY,
8
+ dependsOn: [
9
+ HOZON_DB
10
+ ],
11
+ async setup (ctx) {
12
+ const db = ctx.use(HOZON_DB);
13
+ db.register(replayStoreDefinition);
14
+ const store = await db.getStore(REPLAY_STORE);
15
+ const timer = setInterval(()=>{
16
+ void store.cleanup().catch((error)=>{
17
+ ctx.logger.error('replay cleanup failed', {
18
+ error
19
+ });
20
+ });
21
+ }, params.cleanupIntervalMs ?? 60_000);
22
+ ctx.onShutdown(()=>clearInterval(timer));
23
+ return {
24
+ cache: store
25
+ };
26
+ }
27
+ };
28
+ }
@@ -0,0 +1,14 @@
1
+ import type { ReplayCache } from '@enkaku/server';
2
+ import type { Kysely, StoreDefinition } from '@hozon/db';
3
+ export type ReplayTables = {
4
+ replay_keys: {
5
+ key: string;
6
+ expires_at: number;
7
+ };
8
+ };
9
+ export type ReplayStore = ReplayCache & {
10
+ cleanup(): Promise<void>;
11
+ };
12
+ export declare const REPLAY_STORE = "teikyo-replay";
13
+ export declare function createReplayStoreAPI(db: Kysely<ReplayTables>): ReplayStore;
14
+ export declare const replayStoreDefinition: StoreDefinition<ReplayTables, ReplayStore>;
@@ -0,0 +1,36 @@
1
+ import { withStoreTransaction } from '@hozon/db';
2
+ export const REPLAY_STORE = 'teikyo-replay';
3
+ export function createReplayStoreAPI(db) {
4
+ return {
5
+ async checkAndRecord (key, expiresAt) {
6
+ return await withStoreTransaction(db, async (tx)=>{
7
+ // Enkaku accepts tokens at expiry equality, so retain their replay records too.
8
+ await tx.deleteFrom('replay_keys').where('key', '=', key).where('expires_at', '<', Date.now()).execute();
9
+ const result = await tx.insertInto('replay_keys').values({
10
+ key,
11
+ expires_at: expiresAt
12
+ }).onConflict((conflict)=>conflict.column('key').doNothing()).executeTakeFirst();
13
+ return Number(result.numInsertedOrUpdatedRows) === 1;
14
+ });
15
+ },
16
+ async cleanup () {
17
+ await db.deleteFrom('replay_keys').where('expires_at', '<', Date.now()).execute();
18
+ }
19
+ };
20
+ }
21
+ export const replayStoreDefinition = {
22
+ name: REPLAY_STORE,
23
+ migrations: (ctx)=>{
24
+ return {
25
+ '0-init': {
26
+ async up (db) {
27
+ await db.schema.createTable('replay_keys').addColumn('key', ctx.types.text, (column)=>column.notNull().primaryKey()).addColumn('expires_at', ctx.types.bigint, (column)=>column.notNull()).execute();
28
+ },
29
+ async down (db) {
30
+ await db.schema.dropTable('replay_keys').execute();
31
+ }
32
+ }
33
+ };
34
+ },
35
+ createAPI: createReplayStoreAPI
36
+ };
@@ -25,4 +25,7 @@ export type RevocationHookParams = {
25
25
  * a revocation are logged through `onError` and surface as `Access denied`, because Enkaku copies
26
26
  * error messages to the client.
27
27
  */
28
- export declare function createRevocationHook(params: RevocationHookParams): (token: SignedToken, raw: string) => Promise<void>;
28
+ export declare function createRevocationHook(params: RevocationHookParams): (params: {
29
+ token: SignedToken;
30
+ raw: string;
31
+ }) => Promise<void>;
@@ -21,8 +21,10 @@ function toRecord(row) {
21
21
  return (db)=>{
22
22
  return {
23
23
  async add (record) {
24
- const verified = await verifyToken(record, {
24
+ const verified = await verifyToken({
25
+ token: record,
25
26
  historic: true,
27
+ crypto: options.crypto,
26
28
  resolver: options.resolver,
27
29
  cache: options.cache,
28
30
  methods: options.methods
@@ -44,7 +46,7 @@ function toRecord(row) {
44
46
  'jti'
45
47
  ]).doNothing()).execute();
46
48
  },
47
- async get (jti, issuer) {
49
+ async get ({ jti, issuer }) {
48
50
  const row = await db.selectFrom('revocations').select([
49
51
  'data',
50
52
  'signature'
@@ -67,12 +69,18 @@ function toRecord(row) {
67
69
  * error messages to the client.
68
70
  */ export function createRevocationHook(params) {
69
71
  // Parameter widened to any signed token so plain invocation tokens can be checked too.
70
- return async (token, raw)=>{
72
+ return async ({ token, raw })=>{
71
73
  if (params.requireJTI && token.payload.jti == null) {
72
74
  throw new Error('Token without jti rejected');
73
75
  }
74
76
  try {
75
- await createRevocationChecker(params.backend, params.options)(token, raw);
77
+ await createRevocationChecker({
78
+ ...params.options,
79
+ backend: params.backend
80
+ })({
81
+ token: token,
82
+ raw
83
+ });
76
84
  } catch (cause) {
77
85
  if (cause instanceof Error && cause.message.startsWith('Token revoked:')) {
78
86
  throw cause;
@@ -6,7 +6,10 @@ import { type RevocationStoreAPI } from './backend.js';
6
6
  export type AccessRevocation = {
7
7
  backend: RevocationStoreAPI;
8
8
  /** Enkaku `verifyToken` hook rejecting revoked signed tokens. */
9
- verifyToken: (token: SignedToken, raw: string) => Promise<void>;
9
+ verifyToken: (params: {
10
+ token: SignedToken;
11
+ raw: string;
12
+ }) => Promise<void>;
10
13
  };
11
14
  export declare const ACCESS_REVOCATION: import("@sozai/http-server").PluginName<"access:revocation", AccessRevocation>;
12
15
  export type AccessRevocationPluginParams = RevocationOptions & {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teikyo/access",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Access grants and revocation for Enkaku servers",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,20 +19,22 @@
19
19
  "lib/*"
20
20
  ],
21
21
  "dependencies": {
22
- "@enkaku/server": "^0.22.2",
23
- "@hozon/db": "^0.5.0",
24
- "@kokuin/capability": "^0.5.2",
25
- "@kokuin/token": "^0.6.2",
26
- "@sozai/codec": "^0.4.3",
22
+ "@enkaku/protocol": "^0.23.1",
23
+ "@enkaku/server": "^0.23.1",
24
+ "@hozon/db": "^0.5.1",
25
+ "@kokuin/capability": "^0.7.0",
26
+ "@kokuin/token": "^0.7.0",
27
+ "@sozai/codec": "^0.4.4",
27
28
  "@sozai/http-server": "^0.2.1",
28
29
  "@sozai/log": "^0.4.0",
29
30
  "@teikyo/hozon": "^0.4.0"
30
31
  },
31
32
  "devDependencies": {
32
- "@hozon/provider": "^0.3.0",
33
- "@types/node": "^26.6.5",
33
+ "@hozon/provider": "^0.3.1",
34
+ "@kokuin/crypto": "^0.7.1",
35
+ "@types/node": "^26.6.6",
34
36
  "del-cli": "^8.0.1",
35
- "vitest": "5.0.5"
37
+ "vitest": "5.1.0"
36
38
  },
37
39
  "engines": {
38
40
  "node": ">=24"