@pegma/authorization-admin 0.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 RetireGolden, LLC
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 ADDED
@@ -0,0 +1,17 @@
1
+ # @pegma/authorization-admin
2
+
3
+ HTTP-neutral audited role administration for Authorization Core hosts.
4
+
5
+ ```sh
6
+ npm install @pegma/authorization-admin @pegma/authorization-storage @pegma/storage-core
7
+ ```
8
+
9
+ The package owns the role-management logic both reference hosts duplicated:
10
+ a grants view with an explicit management policy, audited assign, audited
11
+ revoke with the last-administrator guard (serialize, pre-check, re-verify,
12
+ compensate), per-principal lifecycle history, and the one-time seed helper.
13
+ The host owns the HTTP envelope, UI, principal lookup, rate limiting, and
14
+ the authorization of the service's own callers — one admin tool per site,
15
+ separate instances, nothing crossing hosts.
16
+
17
+ See the [administration guide](https://github.com/pegma-dev/authorization-core/blob/main/docs/ADMINISTRATION.md).
@@ -0,0 +1,173 @@
1
+ /**
2
+ * HTTP-neutral audited role administration for Authorization Core hosts.
3
+ *
4
+ * Extracted from the two reference hosts per `docs/ADMINISTRATION.md`. The
5
+ * service owns grants rendering with an explicit management policy, audited
6
+ * assign, audited revoke with the last-administrator guard, per-principal
7
+ * lifecycle history, and the one-time seed helper. The HOST owns the HTTP
8
+ * envelope, UI, principal lookup, rate limiting, and the authorization of
9
+ * the service's own callers — nothing here re-checks permissions.
10
+ */
11
+ import type { ActiveRoleAssignment, PrincipalId, RoleAssignmentActor, RoleAssignmentScope, RoleName } from "@pegma/authorization-contracts";
12
+ import type { AuditedRoleAssignmentMutationStore, RoleAssignmentReader, VersionedRoleAssignment } from "@pegma/authorization-storage";
13
+ /** The store surface the service needs: reads plus audited mutations. */
14
+ export interface RoleAdministrationStore extends RoleAssignmentReader, AuditedRoleAssignmentMutationStore {
15
+ }
16
+ /** One superset row in the host's by-role holder index. */
17
+ export interface RoleHolderIndexRow {
18
+ readonly principalId: PrincipalId;
19
+ readonly assignmentId: string;
20
+ readonly role: RoleName;
21
+ }
22
+ /**
23
+ * The host-provided by-role index (`docs/STORAGE.md` recipe): rows are
24
+ * written BEFORE grants, never deleted, and verified against the
25
+ * authoritative store on every read. The index may over-report; it must
26
+ * never under-report a grant that exists.
27
+ */
28
+ export interface RoleHolderIndex {
29
+ readonly record: (row: RoleHolderIndexRow) => Promise<void>;
30
+ readonly listByRole: (role: RoleName) => Promise<readonly RoleHolderIndexRow[]>;
31
+ }
32
+ /** System actor written by the guard's post-revoke compensation grant. */
33
+ export declare const GUARD_COMPENSATION_SYSTEM_ID = "last-administrator-guard";
34
+ /** Default system actor for `ensureSeededAssignment`. */
35
+ export declare const SEED_SYSTEM_ID = "bootstrap";
36
+ /**
37
+ * Explicit management policy. Assignments granted by system actors are
38
+ * locked (`managedBy: "system"`) unless the actor is declared ONE-TIME
39
+ * here: a one-time actor writes once and never touches the assignment
40
+ * again, so the record is human-managed like any operator grant. The seed
41
+ * and guard-compensation actors are one-time by definition and are always
42
+ * included.
43
+ */
44
+ export interface RoleManagementPolicy {
45
+ /** The role the last-administrator guard protects. */
46
+ readonly administratorRole: RoleName;
47
+ /** Additional host-declared one-time system actor ids. */
48
+ readonly oneTimeSystemActors?: ReadonlySet<string>;
49
+ }
50
+ /** Who may edit an assignment through the administration surface. */
51
+ export type ManagedBy = "system" | "human";
52
+ /** One active assignment with its management label. */
53
+ export interface AdministeredAssignment {
54
+ readonly assignment: ActiveRoleAssignment;
55
+ readonly managedBy: ManagedBy;
56
+ }
57
+ /** One rendered lifecycle event for the per-principal history view. */
58
+ export interface RoleAdministrationEvent {
59
+ readonly assignmentId: string;
60
+ readonly role: RoleName;
61
+ readonly kind: "granted" | "revoked";
62
+ readonly actor: RoleAssignmentActor;
63
+ readonly atEpochMs: number;
64
+ readonly reason?: string;
65
+ }
66
+ /** Command for an audited operator grant. */
67
+ export interface AssignRoleCommand {
68
+ readonly principalId: PrincipalId;
69
+ readonly role: RoleName;
70
+ readonly scope: RoleAssignmentScope;
71
+ readonly actor: RoleAssignmentActor;
72
+ }
73
+ /** Result of one audited operator grant. */
74
+ export type AssignRoleResult = Readonly<{
75
+ readonly status: "assigned";
76
+ readonly record: VersionedRoleAssignment<ActiveRoleAssignment>;
77
+ }> | Readonly<{
78
+ readonly status: "duplicate";
79
+ }> | Readonly<{
80
+ readonly status: "conflict";
81
+ }>;
82
+ /** Command for an audited operator revocation. */
83
+ export interface RevokeRoleCommand {
84
+ readonly assignmentId: string;
85
+ readonly actor: RoleAssignmentActor;
86
+ readonly reason?: string;
87
+ }
88
+ /**
89
+ * Result of one audited operator revocation.
90
+ *
91
+ * `compensated` reports that the post-revoke re-verification found no
92
+ * active administrator remaining (a concurrent revoke on another instance
93
+ * won its race) and the guard wrote a compensation grant restoring the
94
+ * revoked principal. See "What the guard does NOT promise" in
95
+ * `docs/ADMINISTRATION.md` for the honest limits of this treatment.
96
+ */
97
+ export type RevokeRoleResult = Readonly<{
98
+ readonly status: "revoked";
99
+ readonly compensated: boolean;
100
+ }> | Readonly<{
101
+ readonly status: "not_found";
102
+ }> | Readonly<{
103
+ readonly status: "already_revoked";
104
+ }> | Readonly<{
105
+ readonly status: "system_managed";
106
+ }> | Readonly<{
107
+ readonly status: "last_administrator";
108
+ }> | Readonly<{
109
+ readonly status: "conflict";
110
+ }>;
111
+ /** Constructor options for {@link createRoleAdministration}. */
112
+ export interface RoleAdministrationOptions {
113
+ readonly store: RoleAdministrationStore;
114
+ readonly holderIndex: RoleHolderIndex;
115
+ readonly policy: RoleManagementPolicy;
116
+ /** Epoch-milliseconds clock; injectable for deterministic tests. */
117
+ readonly now?: () => number;
118
+ /** Fresh opaque id source for grants and audit events. */
119
+ readonly generateId?: () => string;
120
+ }
121
+ /** The administration service. One instance per application; host-gated. */
122
+ export interface RoleAdministration {
123
+ readonly viewGrants: (principalId: PrincipalId, scope: RoleAssignmentScope) => Promise<readonly AdministeredAssignment[]>;
124
+ readonly listHistory: (principalId: PrincipalId, scope: RoleAssignmentScope) => Promise<readonly RoleAdministrationEvent[]>;
125
+ readonly assignRole: (command: AssignRoleCommand) => Promise<AssignRoleResult>;
126
+ readonly revokeRole: (command: RevokeRoleCommand) => Promise<RevokeRoleResult>;
127
+ readonly anotherActiveHolderExists: (role: RoleName, scope: RoleAssignmentScope, excludingPrincipalId: PrincipalId | "") => Promise<boolean>;
128
+ }
129
+ /**
130
+ * Management label for one assignment under one policy. Grants by humans
131
+ * are human-managed; grants by system actors are locked unless the actor
132
+ * is one-time (the seed and guard-compensation actors always are).
133
+ */
134
+ export declare function assignmentManagedBy(assignment: Pick<ActiveRoleAssignment, "grantedBy">, policy: RoleManagementPolicy): ManagedBy;
135
+ /** Options for {@link ensureSeededAssignment}. */
136
+ export interface EnsureSeededAssignmentOptions {
137
+ readonly store: RoleAdministrationStore;
138
+ /** When provided, the superset row is recorded before the grant. */
139
+ readonly holderIndex?: RoleHolderIndex;
140
+ readonly principalId: PrincipalId;
141
+ readonly role: RoleName;
142
+ readonly scope: RoleAssignmentScope;
143
+ /**
144
+ * Fresh opaque assignment id from the host's ceremony manifest, retained
145
+ * there for retries. NOT derived from the principal or role
146
+ * (`docs/ROLE_ASSIGNMENTS.md`); an exact replay is the store's ordinary
147
+ * `unchanged`, and the history check below converges even a lost
148
+ * manifest.
149
+ */
150
+ readonly assignmentId: string;
151
+ /** Audit event id, equally fresh and manifest-retained. */
152
+ readonly auditEventId: string;
153
+ readonly systemId?: string;
154
+ readonly now?: () => number;
155
+ }
156
+ /**
157
+ * Seed one role for one principal, once per principal AND ROLE, ever: any
158
+ * existing assignment record for that role — active or revoked, whatever
159
+ * its provenance — is durable already-seeded evidence, so a deliberate
160
+ * revocation is never resurrected by a lingering seed input. The ceremony
161
+ * in `docs/ADMINISTRATOR_BOOTSTRAP.md` decides whether and for whom to
162
+ * call this; the helper is a pure function over the ports.
163
+ *
164
+ * `conflict` means the manifest is CONTRADICTORY — its assignment id is
165
+ * already claimed by a different lifecycle — and the principal may still
166
+ * hold nothing: the ceremony must fail closed, not report convergence.
167
+ * (A concurrent duplicate run converges through the store's `unchanged`
168
+ * replay, never through a conflict.)
169
+ */
170
+ export declare function ensureSeededAssignment(options: EnsureSeededAssignmentOptions): Promise<"granted" | "already" | "conflict">;
171
+ /** Creates the administration service over host-owned ports. */
172
+ export declare function createRoleAdministration(options: RoleAdministrationOptions): RoleAdministration;
173
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js ADDED
@@ -0,0 +1,271 @@
1
+ /** System actor written by the guard's post-revoke compensation grant. */
2
+ export const GUARD_COMPENSATION_SYSTEM_ID = "last-administrator-guard";
3
+ /** Default system actor for `ensureSeededAssignment`. */
4
+ export const SEED_SYSTEM_ID = "bootstrap";
5
+ const alwaysOneTime = new Set([GUARD_COMPENSATION_SYSTEM_ID, SEED_SYSTEM_ID]);
6
+ /**
7
+ * Management label for one assignment under one policy. Grants by humans
8
+ * are human-managed; grants by system actors are locked unless the actor
9
+ * is one-time (the seed and guard-compensation actors always are).
10
+ */
11
+ export function assignmentManagedBy(assignment, policy) {
12
+ const grantedBy = assignment.grantedBy;
13
+ if (grantedBy.kind !== "system") {
14
+ return "human";
15
+ }
16
+ return alwaysOneTime.has(grantedBy.systemId) ||
17
+ (policy.oneTimeSystemActors?.has(grantedBy.systemId) ?? false)
18
+ ? "human"
19
+ : "system";
20
+ }
21
+ /**
22
+ * Seed one role for one principal, once per principal AND ROLE, ever: any
23
+ * existing assignment record for that role — active or revoked, whatever
24
+ * its provenance — is durable already-seeded evidence, so a deliberate
25
+ * revocation is never resurrected by a lingering seed input. The ceremony
26
+ * in `docs/ADMINISTRATOR_BOOTSTRAP.md` decides whether and for whom to
27
+ * call this; the helper is a pure function over the ports.
28
+ *
29
+ * `conflict` means the manifest is CONTRADICTORY — its assignment id is
30
+ * already claimed by a different lifecycle — and the principal may still
31
+ * hold nothing: the ceremony must fail closed, not report convergence.
32
+ * (A concurrent duplicate run converges through the store's `unchanged`
33
+ * replay, never through a conflict.)
34
+ */
35
+ export async function ensureSeededAssignment(options) {
36
+ const { store, holderIndex, principalId, role, scope } = options;
37
+ const history = await store.listRoleAssignments(principalId, scope);
38
+ if (history.some((assignment) => assignment.role === role)) {
39
+ return "already";
40
+ }
41
+ if (holderIndex !== undefined) {
42
+ await holderIndex.record({
43
+ principalId,
44
+ assignmentId: options.assignmentId,
45
+ role,
46
+ });
47
+ }
48
+ const result = await store.grantRoleAssignmentWithAudit({
49
+ assignment: {
50
+ id: options.assignmentId,
51
+ principalId,
52
+ role,
53
+ scope,
54
+ grantedBy: {
55
+ kind: "system",
56
+ systemId: options.systemId ?? SEED_SYSTEM_ID,
57
+ },
58
+ grantedAtEpochMs: (options.now ?? Date.now)(),
59
+ status: "active",
60
+ },
61
+ auditEventId: options.auditEventId,
62
+ });
63
+ if (result.status === "granted") {
64
+ return "granted";
65
+ }
66
+ return result.status === "unchanged" ? "already" : "conflict";
67
+ }
68
+ /** Exact-scope equality: the guard never counts holders across scopes. */
69
+ function sameScope(left, right) {
70
+ if (left.kind === "application") {
71
+ return right.kind === "application";
72
+ }
73
+ return (right.kind === "organization" &&
74
+ right.organizationId === left.organizationId);
75
+ }
76
+ /** Creates the administration service over host-owned ports. */
77
+ export function createRoleAdministration(options) {
78
+ const { store, holderIndex, policy } = options;
79
+ const now = options.now ?? (() => Date.now());
80
+ const generateId = options.generateId ?? (() => crypto.randomUUID());
81
+ const anotherActiveHolderExists = async (role, scope, excludingPrincipalId) => {
82
+ const candidates = await holderIndex.listByRole(role);
83
+ for (const candidate of candidates) {
84
+ if (candidate.principalId === excludingPrincipalId) {
85
+ continue;
86
+ }
87
+ // Rows are candidates only; the authoritative store decides — and
88
+ // only an EXACT-scope holder counts: an organization-scoped
89
+ // administrator is no answer to losing the last application-scoped
90
+ // one.
91
+ const current = await store.getRoleAssignment(candidate.assignmentId);
92
+ if (current !== null &&
93
+ current.assignment.status === "active" &&
94
+ current.assignment.role === role &&
95
+ current.assignment.principalId === candidate.principalId &&
96
+ sameScope(current.assignment.scope, scope)) {
97
+ return true;
98
+ }
99
+ }
100
+ return false;
101
+ };
102
+ // Revocations are serialized in-process so two concurrent revokes of the
103
+ // two last administrators cannot interleave past the guard. The chain
104
+ // never rejects: each task's failure belongs to its own caller.
105
+ let revokeChain = Promise.resolve();
106
+ const serialized = (task) => {
107
+ const next = revokeChain.then(task, task);
108
+ revokeChain = next.catch(() => undefined);
109
+ return next;
110
+ };
111
+ const revokeRole = (command) => serialized(async () => {
112
+ const current = await store.getRoleAssignment(command.assignmentId);
113
+ if (current === null) {
114
+ return { status: "not_found" };
115
+ }
116
+ if (current.assignment.status === "revoked") {
117
+ return { status: "already_revoked" };
118
+ }
119
+ const assignment = current.assignment;
120
+ if (assignmentManagedBy(assignment, policy) === "system") {
121
+ return { status: "system_managed" };
122
+ }
123
+ const guarded = assignment.role === policy.administratorRole;
124
+ if (guarded &&
125
+ !(await anotherActiveHolderExists(assignment.role, assignment.scope, assignment.principalId))) {
126
+ return { status: "last_administrator" };
127
+ }
128
+ const revoked = await store.revokeRoleAssignmentWithAudit({
129
+ assignmentId: assignment.id,
130
+ expectedConcurrencyToken: current.concurrencyToken,
131
+ revokedBy: command.actor,
132
+ revokedAtEpochMs: now(),
133
+ ...(command.reason === undefined ? {} : { reason: command.reason }),
134
+ auditEventId: generateId(),
135
+ });
136
+ if (revoked.status === "not_found") {
137
+ return { status: "not_found" };
138
+ }
139
+ if (revoked.status === "conflict") {
140
+ return { status: "conflict" };
141
+ }
142
+ if (!guarded) {
143
+ return { status: "revoked", compensated: false };
144
+ }
145
+ // Re-verify after the commit: a concurrent revoke on ANOTHER instance
146
+ // may have removed the holder the pre-check relied on. If no active
147
+ // administrator remains, restore the principal just revoked with a
148
+ // one-time system actor (human-managed, revocable). The residual
149
+ // crash window and its documented recovery live in
150
+ // docs/ADMINISTRATION.md.
151
+ if (await anotherActiveHolderExists(assignment.role, assignment.scope, "")) {
152
+ return { status: "revoked", compensated: false };
153
+ }
154
+ const compensationId = generateId();
155
+ await holderIndex.record({
156
+ principalId: assignment.principalId,
157
+ assignmentId: compensationId,
158
+ role: assignment.role,
159
+ });
160
+ const compensation = await store.grantRoleAssignmentWithAudit({
161
+ assignment: {
162
+ id: compensationId,
163
+ principalId: assignment.principalId,
164
+ role: assignment.role,
165
+ scope: assignment.scope,
166
+ grantedBy: {
167
+ kind: "system",
168
+ systemId: GUARD_COMPENSATION_SYSTEM_ID,
169
+ },
170
+ grantedAtEpochMs: now(),
171
+ status: "active",
172
+ },
173
+ auditEventId: generateId(),
174
+ });
175
+ if (compensation.status === "conflict") {
176
+ // `active_tuple` means another writer restored this principal in
177
+ // the meantime — verify that an administrator genuinely exists
178
+ // before declining to compensate. Anything else unverified is the
179
+ // documented crash-window failure: surface it, never report a
180
+ // guarantee that does not hold.
181
+ if (await anotherActiveHolderExists(assignment.role, assignment.scope, "")) {
182
+ return { status: "revoked", compensated: false };
183
+ }
184
+ throw new Error("administrator compensation failed; recover via the bootstrap ceremony (docs/ADMINISTRATION.md)");
185
+ }
186
+ return { status: "revoked", compensated: true };
187
+ });
188
+ const service = {
189
+ async viewGrants(principalId, scope) {
190
+ const active = await store.listActiveRoleAssignments(principalId, scope);
191
+ // Self-heal the superset: an assignment the store reports but the
192
+ // index misses (a grant that predates the index) is re-recorded so
193
+ // the guard's by-role candidate set converges. Idempotent puts.
194
+ for (const assignment of active) {
195
+ await holderIndex.record({
196
+ principalId,
197
+ assignmentId: assignment.id,
198
+ role: assignment.role,
199
+ });
200
+ }
201
+ return active.map((assignment) => ({
202
+ assignment,
203
+ managedBy: assignmentManagedBy(assignment, policy),
204
+ }));
205
+ },
206
+ async listHistory(principalId, scope) {
207
+ const held = await store.listRoleAssignments(principalId, scope);
208
+ const events = [];
209
+ for (const assignment of held) {
210
+ events.push({
211
+ assignmentId: assignment.id,
212
+ role: assignment.role,
213
+ kind: "granted",
214
+ actor: assignment.grantedBy,
215
+ atEpochMs: assignment.grantedAtEpochMs,
216
+ });
217
+ if (assignment.status === "revoked") {
218
+ events.push({
219
+ assignmentId: assignment.id,
220
+ role: assignment.role,
221
+ kind: "revoked",
222
+ actor: assignment.revokedBy,
223
+ atEpochMs: assignment.revokedAtEpochMs,
224
+ ...(assignment.reason === undefined
225
+ ? {}
226
+ : { reason: assignment.reason }),
227
+ });
228
+ }
229
+ }
230
+ events.sort((left, right) => left.atEpochMs - right.atEpochMs);
231
+ return events;
232
+ },
233
+ async assignRole(command) {
234
+ const active = await store.listActiveRoleAssignments(command.principalId, command.scope);
235
+ // Pre-check so a routine duplicate never writes an index row; the
236
+ // store's active-tuple guarantee still decides races past this.
237
+ if (active.some((assignment) => assignment.role === command.role)) {
238
+ return { status: "duplicate" };
239
+ }
240
+ const assignmentId = generateId();
241
+ await holderIndex.record({
242
+ principalId: command.principalId,
243
+ assignmentId,
244
+ role: command.role,
245
+ });
246
+ const result = await store.grantRoleAssignmentWithAudit({
247
+ assignment: {
248
+ id: assignmentId,
249
+ principalId: command.principalId,
250
+ role: command.role,
251
+ scope: command.scope,
252
+ grantedBy: command.actor,
253
+ grantedAtEpochMs: now(),
254
+ status: "active",
255
+ },
256
+ auditEventId: generateId(),
257
+ });
258
+ if (result.status === "granted") {
259
+ return { status: "assigned", record: result.record };
260
+ }
261
+ if (result.status === "conflict" && result.reason === "active_tuple") {
262
+ return { status: "duplicate" };
263
+ }
264
+ return { status: "conflict" };
265
+ },
266
+ revokeRole,
267
+ anotherActiveHolderExists,
268
+ };
269
+ return Object.freeze(service);
270
+ }
271
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * HTTP-neutral audited role administration for Authorization Core hosts.\n *\n * Extracted from the two reference hosts per `docs/ADMINISTRATION.md`. The\n * service owns grants rendering with an explicit management policy, audited\n * assign, audited revoke with the last-administrator guard, per-principal\n * lifecycle history, and the one-time seed helper. The HOST owns the HTTP\n * envelope, UI, principal lookup, rate limiting, and the authorization of\n * the service's own callers — nothing here re-checks permissions.\n */\nimport type {\n ActiveRoleAssignment,\n PrincipalId,\n RoleAssignmentActor,\n RoleAssignmentScope,\n RoleName,\n} from \"@pegma/authorization-contracts\";\nimport type {\n AuditedRoleAssignmentMutationStore,\n RoleAssignmentReader,\n VersionedRoleAssignment,\n} from \"@pegma/authorization-storage\";\n\n/** The store surface the service needs: reads plus audited mutations. */\nexport interface RoleAdministrationStore\n extends RoleAssignmentReader, AuditedRoleAssignmentMutationStore {}\n\n/** One superset row in the host's by-role holder index. */\nexport interface RoleHolderIndexRow {\n readonly principalId: PrincipalId;\n readonly assignmentId: string;\n readonly role: RoleName;\n}\n\n/**\n * The host-provided by-role index (`docs/STORAGE.md` recipe): rows are\n * written BEFORE grants, never deleted, and verified against the\n * authoritative store on every read. The index may over-report; it must\n * never under-report a grant that exists.\n */\nexport interface RoleHolderIndex {\n readonly record: (row: RoleHolderIndexRow) => Promise<void>;\n readonly listByRole: (\n role: RoleName,\n ) => Promise<readonly RoleHolderIndexRow[]>;\n}\n\n/** System actor written by the guard's post-revoke compensation grant. */\nexport const GUARD_COMPENSATION_SYSTEM_ID = \"last-administrator-guard\";\n\n/** Default system actor for `ensureSeededAssignment`. */\nexport const SEED_SYSTEM_ID = \"bootstrap\";\n\n/**\n * Explicit management policy. Assignments granted by system actors are\n * locked (`managedBy: \"system\"`) unless the actor is declared ONE-TIME\n * here: a one-time actor writes once and never touches the assignment\n * again, so the record is human-managed like any operator grant. The seed\n * and guard-compensation actors are one-time by definition and are always\n * included.\n */\nexport interface RoleManagementPolicy {\n /** The role the last-administrator guard protects. */\n readonly administratorRole: RoleName;\n /** Additional host-declared one-time system actor ids. */\n readonly oneTimeSystemActors?: ReadonlySet<string>;\n}\n\n/** Who may edit an assignment through the administration surface. */\nexport type ManagedBy = \"system\" | \"human\";\n\n/** One active assignment with its management label. */\nexport interface AdministeredAssignment {\n readonly assignment: ActiveRoleAssignment;\n readonly managedBy: ManagedBy;\n}\n\n/** One rendered lifecycle event for the per-principal history view. */\nexport interface RoleAdministrationEvent {\n readonly assignmentId: string;\n readonly role: RoleName;\n readonly kind: \"granted\" | \"revoked\";\n readonly actor: RoleAssignmentActor;\n readonly atEpochMs: number;\n readonly reason?: string;\n}\n\n/** Command for an audited operator grant. */\nexport interface AssignRoleCommand {\n readonly principalId: PrincipalId;\n readonly role: RoleName;\n readonly scope: RoleAssignmentScope;\n readonly actor: RoleAssignmentActor;\n}\n\n/** Result of one audited operator grant. */\nexport type AssignRoleResult =\n | Readonly<{\n readonly status: \"assigned\";\n readonly record: VersionedRoleAssignment<ActiveRoleAssignment>;\n }>\n | Readonly<{ readonly status: \"duplicate\" }>\n | Readonly<{ readonly status: \"conflict\" }>;\n\n/** Command for an audited operator revocation. */\nexport interface RevokeRoleCommand {\n readonly assignmentId: string;\n readonly actor: RoleAssignmentActor;\n readonly reason?: string;\n}\n\n/**\n * Result of one audited operator revocation.\n *\n * `compensated` reports that the post-revoke re-verification found no\n * active administrator remaining (a concurrent revoke on another instance\n * won its race) and the guard wrote a compensation grant restoring the\n * revoked principal. See \"What the guard does NOT promise\" in\n * `docs/ADMINISTRATION.md` for the honest limits of this treatment.\n */\nexport type RevokeRoleResult =\n | Readonly<{\n readonly status: \"revoked\";\n readonly compensated: boolean;\n }>\n | Readonly<{ readonly status: \"not_found\" }>\n | Readonly<{ readonly status: \"already_revoked\" }>\n | Readonly<{ readonly status: \"system_managed\" }>\n | Readonly<{ readonly status: \"last_administrator\" }>\n | Readonly<{ readonly status: \"conflict\" }>;\n\n/** Constructor options for {@link createRoleAdministration}. */\nexport interface RoleAdministrationOptions {\n readonly store: RoleAdministrationStore;\n readonly holderIndex: RoleHolderIndex;\n readonly policy: RoleManagementPolicy;\n /** Epoch-milliseconds clock; injectable for deterministic tests. */\n readonly now?: () => number;\n /** Fresh opaque id source for grants and audit events. */\n readonly generateId?: () => string;\n}\n\n/** The administration service. One instance per application; host-gated. */\nexport interface RoleAdministration {\n readonly viewGrants: (\n principalId: PrincipalId,\n scope: RoleAssignmentScope,\n ) => Promise<readonly AdministeredAssignment[]>;\n readonly listHistory: (\n principalId: PrincipalId,\n scope: RoleAssignmentScope,\n ) => Promise<readonly RoleAdministrationEvent[]>;\n readonly assignRole: (\n command: AssignRoleCommand,\n ) => Promise<AssignRoleResult>;\n readonly revokeRole: (\n command: RevokeRoleCommand,\n ) => Promise<RevokeRoleResult>;\n readonly anotherActiveHolderExists: (\n role: RoleName,\n scope: RoleAssignmentScope,\n excludingPrincipalId: PrincipalId | \"\",\n ) => Promise<boolean>;\n}\n\nconst alwaysOneTime = new Set([GUARD_COMPENSATION_SYSTEM_ID, SEED_SYSTEM_ID]);\n\n/**\n * Management label for one assignment under one policy. Grants by humans\n * are human-managed; grants by system actors are locked unless the actor\n * is one-time (the seed and guard-compensation actors always are).\n */\nexport function assignmentManagedBy(\n assignment: Pick<ActiveRoleAssignment, \"grantedBy\">,\n policy: RoleManagementPolicy,\n): ManagedBy {\n const grantedBy = assignment.grantedBy;\n if (grantedBy.kind !== \"system\") {\n return \"human\";\n }\n return alwaysOneTime.has(grantedBy.systemId) ||\n (policy.oneTimeSystemActors?.has(grantedBy.systemId) ?? false)\n ? \"human\"\n : \"system\";\n}\n\n/** Options for {@link ensureSeededAssignment}. */\nexport interface EnsureSeededAssignmentOptions {\n readonly store: RoleAdministrationStore;\n /** When provided, the superset row is recorded before the grant. */\n readonly holderIndex?: RoleHolderIndex;\n readonly principalId: PrincipalId;\n readonly role: RoleName;\n readonly scope: RoleAssignmentScope;\n /**\n * Fresh opaque assignment id from the host's ceremony manifest, retained\n * there for retries. NOT derived from the principal or role\n * (`docs/ROLE_ASSIGNMENTS.md`); an exact replay is the store's ordinary\n * `unchanged`, and the history check below converges even a lost\n * manifest.\n */\n readonly assignmentId: string;\n /** Audit event id, equally fresh and manifest-retained. */\n readonly auditEventId: string;\n readonly systemId?: string;\n readonly now?: () => number;\n}\n\n/**\n * Seed one role for one principal, once per principal AND ROLE, ever: any\n * existing assignment record for that role — active or revoked, whatever\n * its provenance — is durable already-seeded evidence, so a deliberate\n * revocation is never resurrected by a lingering seed input. The ceremony\n * in `docs/ADMINISTRATOR_BOOTSTRAP.md` decides whether and for whom to\n * call this; the helper is a pure function over the ports.\n *\n * `conflict` means the manifest is CONTRADICTORY — its assignment id is\n * already claimed by a different lifecycle — and the principal may still\n * hold nothing: the ceremony must fail closed, not report convergence.\n * (A concurrent duplicate run converges through the store's `unchanged`\n * replay, never through a conflict.)\n */\nexport async function ensureSeededAssignment(\n options: EnsureSeededAssignmentOptions,\n): Promise<\"granted\" | \"already\" | \"conflict\"> {\n const { store, holderIndex, principalId, role, scope } = options;\n const history = await store.listRoleAssignments(principalId, scope);\n if (history.some((assignment) => assignment.role === role)) {\n return \"already\";\n }\n if (holderIndex !== undefined) {\n await holderIndex.record({\n principalId,\n assignmentId: options.assignmentId,\n role,\n });\n }\n const result = await store.grantRoleAssignmentWithAudit({\n assignment: {\n id: options.assignmentId,\n principalId,\n role,\n scope,\n grantedBy: {\n kind: \"system\",\n systemId: options.systemId ?? SEED_SYSTEM_ID,\n },\n grantedAtEpochMs: (options.now ?? Date.now)(),\n status: \"active\",\n },\n auditEventId: options.auditEventId,\n });\n if (result.status === \"granted\") {\n return \"granted\";\n }\n return result.status === \"unchanged\" ? \"already\" : \"conflict\";\n}\n\n/** Exact-scope equality: the guard never counts holders across scopes. */\nfunction sameScope(\n left: RoleAssignmentScope,\n right: RoleAssignmentScope,\n): boolean {\n if (left.kind === \"application\") {\n return right.kind === \"application\";\n }\n return (\n right.kind === \"organization\" &&\n right.organizationId === left.organizationId\n );\n}\n\n/** Creates the administration service over host-owned ports. */\nexport function createRoleAdministration(\n options: RoleAdministrationOptions,\n): RoleAdministration {\n const { store, holderIndex, policy } = options;\n const now = options.now ?? (() => Date.now());\n const generateId = options.generateId ?? (() => crypto.randomUUID());\n\n const anotherActiveHolderExists = async (\n role: RoleName,\n scope: RoleAssignmentScope,\n excludingPrincipalId: PrincipalId | \"\",\n ): Promise<boolean> => {\n const candidates = await holderIndex.listByRole(role);\n for (const candidate of candidates) {\n if (candidate.principalId === excludingPrincipalId) {\n continue;\n }\n // Rows are candidates only; the authoritative store decides — and\n // only an EXACT-scope holder counts: an organization-scoped\n // administrator is no answer to losing the last application-scoped\n // one.\n const current = await store.getRoleAssignment(candidate.assignmentId);\n if (\n current !== null &&\n current.assignment.status === \"active\" &&\n current.assignment.role === role &&\n current.assignment.principalId === candidate.principalId &&\n sameScope(current.assignment.scope, scope)\n ) {\n return true;\n }\n }\n return false;\n };\n\n // Revocations are serialized in-process so two concurrent revokes of the\n // two last administrators cannot interleave past the guard. The chain\n // never rejects: each task's failure belongs to its own caller.\n let revokeChain: Promise<unknown> = Promise.resolve();\n const serialized = <T>(task: () => Promise<T>): Promise<T> => {\n const next = revokeChain.then(task, task);\n revokeChain = next.catch(() => undefined);\n return next;\n };\n\n const revokeRole = (command: RevokeRoleCommand): Promise<RevokeRoleResult> =>\n serialized(async () => {\n const current = await store.getRoleAssignment(command.assignmentId);\n if (current === null) {\n return { status: \"not_found\" };\n }\n if (current.assignment.status === \"revoked\") {\n return { status: \"already_revoked\" };\n }\n const assignment = current.assignment;\n if (assignmentManagedBy(assignment, policy) === \"system\") {\n return { status: \"system_managed\" };\n }\n const guarded = assignment.role === policy.administratorRole;\n if (\n guarded &&\n !(await anotherActiveHolderExists(\n assignment.role,\n assignment.scope,\n assignment.principalId,\n ))\n ) {\n return { status: \"last_administrator\" };\n }\n const revoked = await store.revokeRoleAssignmentWithAudit({\n assignmentId: assignment.id,\n expectedConcurrencyToken: current.concurrencyToken,\n revokedBy: command.actor,\n revokedAtEpochMs: now(),\n ...(command.reason === undefined ? {} : { reason: command.reason }),\n auditEventId: generateId(),\n });\n if (revoked.status === \"not_found\") {\n return { status: \"not_found\" };\n }\n if (revoked.status === \"conflict\") {\n return { status: \"conflict\" };\n }\n if (!guarded) {\n return { status: \"revoked\", compensated: false };\n }\n // Re-verify after the commit: a concurrent revoke on ANOTHER instance\n // may have removed the holder the pre-check relied on. If no active\n // administrator remains, restore the principal just revoked with a\n // one-time system actor (human-managed, revocable). The residual\n // crash window and its documented recovery live in\n // docs/ADMINISTRATION.md.\n if (\n await anotherActiveHolderExists(assignment.role, assignment.scope, \"\")\n ) {\n return { status: \"revoked\", compensated: false };\n }\n const compensationId = generateId();\n await holderIndex.record({\n principalId: assignment.principalId,\n assignmentId: compensationId,\n role: assignment.role,\n });\n const compensation = await store.grantRoleAssignmentWithAudit({\n assignment: {\n id: compensationId,\n principalId: assignment.principalId,\n role: assignment.role,\n scope: assignment.scope,\n grantedBy: {\n kind: \"system\",\n systemId: GUARD_COMPENSATION_SYSTEM_ID,\n },\n grantedAtEpochMs: now(),\n status: \"active\",\n },\n auditEventId: generateId(),\n });\n if (compensation.status === \"conflict\") {\n // `active_tuple` means another writer restored this principal in\n // the meantime — verify that an administrator genuinely exists\n // before declining to compensate. Anything else unverified is the\n // documented crash-window failure: surface it, never report a\n // guarantee that does not hold.\n if (\n await anotherActiveHolderExists(assignment.role, assignment.scope, \"\")\n ) {\n return { status: \"revoked\", compensated: false };\n }\n throw new Error(\n \"administrator compensation failed; recover via the bootstrap ceremony (docs/ADMINISTRATION.md)\",\n );\n }\n return { status: \"revoked\", compensated: true };\n });\n\n const service: RoleAdministration = {\n async viewGrants(principalId, scope) {\n const active = await store.listActiveRoleAssignments(principalId, scope);\n // Self-heal the superset: an assignment the store reports but the\n // index misses (a grant that predates the index) is re-recorded so\n // the guard's by-role candidate set converges. Idempotent puts.\n for (const assignment of active) {\n await holderIndex.record({\n principalId,\n assignmentId: assignment.id,\n role: assignment.role,\n });\n }\n return active.map((assignment) => ({\n assignment,\n managedBy: assignmentManagedBy(assignment, policy),\n }));\n },\n\n async listHistory(principalId, scope) {\n const held = await store.listRoleAssignments(principalId, scope);\n const events: RoleAdministrationEvent[] = [];\n for (const assignment of held) {\n events.push({\n assignmentId: assignment.id,\n role: assignment.role,\n kind: \"granted\",\n actor: assignment.grantedBy,\n atEpochMs: assignment.grantedAtEpochMs,\n });\n if (assignment.status === \"revoked\") {\n events.push({\n assignmentId: assignment.id,\n role: assignment.role,\n kind: \"revoked\",\n actor: assignment.revokedBy,\n atEpochMs: assignment.revokedAtEpochMs,\n ...(assignment.reason === undefined\n ? {}\n : { reason: assignment.reason }),\n });\n }\n }\n events.sort((left, right) => left.atEpochMs - right.atEpochMs);\n return events;\n },\n\n async assignRole(command) {\n const active = await store.listActiveRoleAssignments(\n command.principalId,\n command.scope,\n );\n // Pre-check so a routine duplicate never writes an index row; the\n // store's active-tuple guarantee still decides races past this.\n if (active.some((assignment) => assignment.role === command.role)) {\n return { status: \"duplicate\" };\n }\n const assignmentId = generateId();\n await holderIndex.record({\n principalId: command.principalId,\n assignmentId,\n role: command.role,\n });\n const result = await store.grantRoleAssignmentWithAudit({\n assignment: {\n id: assignmentId,\n principalId: command.principalId,\n role: command.role,\n scope: command.scope,\n grantedBy: command.actor,\n grantedAtEpochMs: now(),\n status: \"active\",\n },\n auditEventId: generateId(),\n });\n if (result.status === \"granted\") {\n return { status: \"assigned\", record: result.record };\n }\n if (result.status === \"conflict\" && result.reason === \"active_tuple\") {\n return { status: \"duplicate\" };\n }\n return { status: \"conflict\" };\n },\n\n revokeRole,\n anotherActiveHolderExists,\n };\n return Object.freeze(service);\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@pegma/authorization-admin",
3
+ "version": "0.0.0",
4
+ "description": "HTTP-neutral audited role-administration service for Authorization Core hosts.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "files": [
8
+ "dist/**/*.d.ts",
9
+ "dist/**/*.d.ts.map",
10
+ "dist/**/*.js",
11
+ "dist/**/*.js.map"
12
+ ],
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.js"
17
+ }
18
+ },
19
+ "engines": {
20
+ "node": ">=22"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/pegma-dev/authorization-core.git",
28
+ "directory": "packages/admin"
29
+ },
30
+ "dependencies": {
31
+ "@pegma/authorization-contracts": "0.3.0",
32
+ "@pegma/authorization-storage": "0.3.0"
33
+ },
34
+ "scripts": {
35
+ "build": "tsc -p tsconfig.json",
36
+ "prepack": "npm run build",
37
+ "check": "tsc -p tsconfig.json --noEmit"
38
+ }
39
+ }