@astram/identity 0.20.0 → 0.22.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.
Files changed (22) hide show
  1. package/dist/features/authz/access-change-notifier.d.ts +109 -0
  2. package/dist/features/authz/access-change-notifier.d.ts.map +1 -0
  3. package/dist/features/authz/access-change-notifier.js +421 -0
  4. package/dist/features/authz/grant-admin.service.d.ts +27 -1
  5. package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
  6. package/dist/features/authz/grant-admin.service.js +52 -2
  7. package/dist/features/identity-feature.module.d.ts.map +1 -1
  8. package/dist/features/identity-feature.module.js +26 -1
  9. package/dist/features/membership/membership.service.d.ts +19 -2
  10. package/dist/features/membership/membership.service.d.ts.map +1 -1
  11. package/dist/features/membership/membership.service.js +57 -5
  12. package/dist/features/membership/posting.service.d.ts +12 -1
  13. package/dist/features/membership/posting.service.d.ts.map +1 -1
  14. package/dist/features/membership/posting.service.js +39 -2
  15. package/dist/features/permission-announcement/permission-announcement.controller.d.ts +22 -0
  16. package/dist/features/permission-announcement/permission-announcement.controller.d.ts.map +1 -0
  17. package/dist/features/permission-announcement/permission-announcement.controller.js +130 -0
  18. package/dist/features/permission-announcement/permission-announcement.service.d.ts +143 -0
  19. package/dist/features/permission-announcement/permission-announcement.service.d.ts.map +1 -0
  20. package/dist/features/permission-announcement/permission-announcement.service.js +389 -0
  21. package/migrations/1710000000109-CreatePermissionAnnouncementDismissal.cjs +46 -0
  22. package/package.json +4 -4
@@ -0,0 +1,130 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __metadata = (this && this.__metadata) || function (k, v) {
9
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
+ };
11
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
12
+ return function (target, key) { decorator(target, key, paramIndex); }
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.PermissionAnnouncementController = void 0;
16
+ exports.parseAnnouncementQuery = parseAnnouncementQuery;
17
+ exports.parsePermissionKeyParam = parsePermissionKeyParam;
18
+ /**
19
+ * Permission announcements (G-027 step 4, spec `docs/features/permission-announcements/SPEC.md`
20
+ * v1 §3, §5.B): the caller's admin tasks for new, unassigned permission keys.
21
+ *
22
+ * Gated `identity:role:update`, the grant routes' own gate: only principals who could open the
23
+ * grant dialog reach these routes; the service then narrows to the caller's company and ceiling.
24
+ * Every path segment is validated here (400) and authorized in the service (404), never trusted.
25
+ */
26
+ const common_1 = require("@nestjs/common");
27
+ const guard_1 = require("@astram/guard");
28
+ const identity_error_interceptor_js_1 = require("../shared/identity-error.interceptor.js");
29
+ const permission_announcement_service_js_1 = require("./permission-announcement.service.js");
30
+ /** An exact permission key `feature:object:action`, segments `^[a-z][a-z0-9_]*$`. */
31
+ const PERMISSION_KEY = /^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*:[a-z][a-z0-9_]*$/;
32
+ /** `role_permission.rp_perm_key` / `pad_perm_key` width. */
33
+ const PERMISSION_KEY_MAX = 191;
34
+ /** A positive whole number in decimal digits, or 400 naming the field (never the value). */
35
+ function positiveInt(value, field, fallback, max) {
36
+ if (value === undefined || value === '')
37
+ return fallback;
38
+ if (typeof value !== 'string' || !/^[0-9]{1,9}$/.test(value)) {
39
+ throw new common_1.BadRequestException(`${field} must be a whole number`);
40
+ }
41
+ const n = Number.parseInt(value, 10);
42
+ if (n < 1 || (max !== undefined && n > max)) {
43
+ throw new common_1.BadRequestException(max === undefined ? `${field} must be at least 1` : `${field} must be between 1 and ${max}`);
44
+ }
45
+ return n;
46
+ }
47
+ /** Validate the list query (spec §3, §13: bad `status`/`page`/`limit`/uuid ⇒ 400). */
48
+ function parseAnnouncementQuery(query) {
49
+ const q = query ?? {};
50
+ const rawStatus = q['status'];
51
+ let status = 'open';
52
+ if (rawStatus !== undefined && rawStatus !== '') {
53
+ if (typeof rawStatus !== 'string' ||
54
+ !permission_announcement_service_js_1.ANNOUNCEMENT_STATUSES.includes(rawStatus)) {
55
+ throw new common_1.BadRequestException(`status must be one of ${permission_announcement_service_js_1.ANNOUNCEMENT_STATUSES.join(', ')}`);
56
+ }
57
+ status = rawStatus;
58
+ }
59
+ const companyUuid = (0, guard_1.optionalUuidField)(q['companyUuid'], 'companyUuid');
60
+ return {
61
+ status,
62
+ page: positiveInt(q['page'], 'page', 1),
63
+ limit: positiveInt(q['limit'], 'limit', permission_announcement_service_js_1.ANNOUNCEMENT_DEFAULT_LIMIT, permission_announcement_service_js_1.ANNOUNCEMENT_MAX_LIMIT),
64
+ ...(companyUuid !== undefined ? { companyUuid } : {}),
65
+ };
66
+ }
67
+ /** Validate a `:key` route param (spec §5.B), or 400 (the value is never echoed). */
68
+ function parsePermissionKeyParam(value) {
69
+ if (typeof value !== 'string' ||
70
+ value.length > PERMISSION_KEY_MAX ||
71
+ !PERMISSION_KEY.test(value)) {
72
+ throw new common_1.BadRequestException('key must be a permission key');
73
+ }
74
+ return value;
75
+ }
76
+ let PermissionAnnouncementController = class PermissionAnnouncementController {
77
+ announcements;
78
+ constructor(announcements) {
79
+ this.announcements = announcements;
80
+ }
81
+ /** The caller's tasks, grouped per company: `{ total, rows, page, limit }`. */
82
+ async list(req, query) {
83
+ return this.announcements.list(req.principal, parseAnnouncementQuery(query));
84
+ }
85
+ /** "Reviewed, not needed" (idempotent, audited). */
86
+ async dismiss(req, companyUuid, key) {
87
+ await this.announcements.dismiss(req.principal, companyUuid, parsePermissionKeyParam(key));
88
+ }
89
+ /** Undo a dismissal (idempotent, audited). */
90
+ async reopen(req, companyUuid, key) {
91
+ await this.announcements.reopen(req.principal, companyUuid, parsePermissionKeyParam(key));
92
+ }
93
+ };
94
+ exports.PermissionAnnouncementController = PermissionAnnouncementController;
95
+ __decorate([
96
+ (0, common_1.Get)('permission-announcements'),
97
+ (0, guard_1.Classified)({ typeKey: 'role', action: 'update' }),
98
+ __param(0, (0, common_1.Req)()),
99
+ __param(1, (0, common_1.Query)()),
100
+ __metadata("design:type", Function),
101
+ __metadata("design:paramtypes", [Object, Object]),
102
+ __metadata("design:returntype", Promise)
103
+ ], PermissionAnnouncementController.prototype, "list", null);
104
+ __decorate([
105
+ (0, common_1.Post)('permission-announcements/:companyUuid/:key/dismiss'),
106
+ (0, common_1.HttpCode)(204),
107
+ (0, guard_1.Classified)({ typeKey: 'role', action: 'update' }),
108
+ __param(0, (0, common_1.Req)()),
109
+ __param(1, (0, common_1.Param)('companyUuid', guard_1.UuidPipe)),
110
+ __param(2, (0, common_1.Param)('key')),
111
+ __metadata("design:type", Function),
112
+ __metadata("design:paramtypes", [Object, String, String]),
113
+ __metadata("design:returntype", Promise)
114
+ ], PermissionAnnouncementController.prototype, "dismiss", null);
115
+ __decorate([
116
+ (0, common_1.Post)('permission-announcements/:companyUuid/:key/reopen'),
117
+ (0, common_1.HttpCode)(204),
118
+ (0, guard_1.Classified)({ typeKey: 'role', action: 'update' }),
119
+ __param(0, (0, common_1.Req)()),
120
+ __param(1, (0, common_1.Param)('companyUuid', guard_1.UuidPipe)),
121
+ __param(2, (0, common_1.Param)('key')),
122
+ __metadata("design:type", Function),
123
+ __metadata("design:paramtypes", [Object, String, String]),
124
+ __metadata("design:returntype", Promise)
125
+ ], PermissionAnnouncementController.prototype, "reopen", null);
126
+ exports.PermissionAnnouncementController = PermissionAnnouncementController = __decorate([
127
+ (0, common_1.Controller)('identity'),
128
+ (0, common_1.UseInterceptors)(identity_error_interceptor_js_1.IdentityErrorInterceptor),
129
+ __metadata("design:paramtypes", [permission_announcement_service_js_1.PermissionAnnouncementService])
130
+ ], PermissionAnnouncementController);
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Permission announcements, step 4 of G-027 (spec `docs/features/permission-announcements/SPEC.md`
3
+ * v1, §5.A): one admin task per (company, new permission key) that no role owned by the company
4
+ * holds yet, shown only to administrators whose grant ceiling covers the key.
5
+ *
6
+ * Open tasks are computed on read (catalog ⋈ the company's roles); only "Reviewed, not needed"
7
+ * dismissals are stored (`<identity>.permission_announcement_dismissal`). A dismissal applies only
8
+ * while its `pad_activated_at` equals the key's current activation (`perm_last_activated_at`), so a
9
+ * key removed and re-added by a later release opens again.
10
+ *
11
+ * Tasks inform only: nothing here grants, changes the ceiling, bumps a version or touches
12
+ * `decide()` (Shield #3). Admin path only, never the request hot path (Shield #12).
13
+ *
14
+ * TIMESTAMPs never cross the driver as a JS `Date` (the authz pool sets no driver `timezone`):
15
+ * activations are copied and compared inside SQL, times are read with `UNIX_TIMESTAMP(col)`
16
+ * (`isoFromEpochSeconds`) and written with `CURRENT_TIMESTAMP(3)`.
17
+ */
18
+ import type { Pool } from 'mariadb';
19
+ import type { FrameworkSchemas, ModuleRegistry, Principal } from '@astram/core';
20
+ import { type AuditPort } from '@astram/engine';
21
+ import { type GrantCeiling } from '../authz/grant-ceiling.js';
22
+ /** The resolved framework names this service needs (the dismissal table lives in `identity`). */
23
+ export type AnnouncementSchemaNames = Pick<FrameworkSchemas, 'app' | 'authz' | 'identity'>;
24
+ /** Task status (spec §10). */
25
+ export declare const ANNOUNCEMENT_STATUSES: readonly ["open", "dismissed"];
26
+ export type AnnouncementStatus = (typeof ANNOUNCEMENT_STATUSES)[number];
27
+ /** Audit actions (spec §10). */
28
+ export type AnnouncementAuditAction = 'permission_announcement.dismissed' | 'permission_announcement.reopened';
29
+ /** Paging bounds (spec §3). */
30
+ export declare const ANNOUNCEMENT_DEFAULT_LIMIT = 20;
31
+ export declare const ANNOUNCEMENT_MAX_LIMIT = 100;
32
+ /** One task row (spec §3). `companyUuid` is the task's own address for dismiss/reopen. */
33
+ export interface PermissionAnnouncementRow {
34
+ companyUuid: string;
35
+ companyName: string;
36
+ key: string;
37
+ label: string;
38
+ description: string | null;
39
+ caution: string | null;
40
+ firstSeenAt: string | null;
41
+ firstSeenVersion: string | null;
42
+ status: AnnouncementStatus;
43
+ dismissedAt?: string | null;
44
+ dismissedByName?: string;
45
+ }
46
+ export interface PermissionAnnouncementQuery {
47
+ companyUuid?: string | undefined;
48
+ status: AnnouncementStatus;
49
+ page: number;
50
+ limit: number;
51
+ }
52
+ export interface PermissionAnnouncementPage {
53
+ total: number;
54
+ rows: PermissionAnnouncementRow[];
55
+ page: number;
56
+ limit: number;
57
+ }
58
+ /** The pool surface used: direct reads plus one connection per write transaction. */
59
+ export type AnnouncementPool = Pick<Pool, 'query' | 'getConnection'>;
60
+ export declare class PermissionAnnouncementService {
61
+ private readonly pool;
62
+ private readonly ceiling;
63
+ private readonly registry;
64
+ private readonly audit;
65
+ private readonly schemas;
66
+ /**
67
+ * @param pool The ONE shared authz pool (reaches `<app>`, `<authz>`, `<identity>`, `<audit>`).
68
+ * @param ceiling The ADR-0029 grant ceiling: a non-root caller sees only keys it could grant.
69
+ * @param registry The shared record-authorization registry (wildcard expansion universe, labels).
70
+ * @param audit Written on the dismissal's own transaction (fail closed, Shield #6).
71
+ * @param schemas Resolved framework names (absent ⇒ resolved from the namespace, once, here).
72
+ */
73
+ constructor(pool: AnnouncementPool, ceiling: Pick<GrantCeiling, 'effectiveKeys'>, registry: ModuleRegistry, audit: AuditPort, schemas?: AnnouncementSchemaNames);
74
+ /** The caller's actionable tasks (spec §5.A `list`). */
75
+ list(actor: Principal, query: PermissionAnnouncementQuery): Promise<PermissionAnnouncementPage>;
76
+ /**
77
+ * "Reviewed, not needed" (spec §5.A `dismiss`). Idempotent: already dismissed for the key's
78
+ * current activation ⇒ no write, no audit. Held in the company ⇒ 409.
79
+ */
80
+ dismiss(actor: Principal, companyUuid: string, key: string): Promise<void>;
81
+ /**
82
+ * Undo a dismissal (spec §5.A `reopen`). Idempotent: no dismissal for the key's current
83
+ * activation ⇒ no write, no audit. A stale row (earlier activation) is not a dismissal and is
84
+ * left as is (the read ignores it).
85
+ */
86
+ reopen(actor: Principal, companyUuid: string, key: string): Promise<void>;
87
+ /**
88
+ * The same authorization as `list` for one (company, key): company reachable (404), key a
89
+ * candidate (404) inside the caller's ceiling (404), and not held in the company (409; the task
90
+ * does not exist, so there is nothing to dismiss or reopen).
91
+ */
92
+ private authorizeTask;
93
+ /**
94
+ * The companies the caller acts in (spec §5.A step 1). Non-root: only the active tenant (a
95
+ * different `companyUuid` ⇒ 404, never trusted). Root: every company, or the one given (unknown
96
+ * ⇒ 404). `all` is true only for root without a filter (the held/dismissal reads then skip IN).
97
+ */
98
+ private companiesFor;
99
+ /**
100
+ * Candidate keys (spec §5.A step 2: active, first seen set) inside the caller's ceiling (step 3:
101
+ * non-root keeps `coversKey(effectiveKeys(actor), registered, key)`; root keeps all). `onlyKey`
102
+ * narrows the read to one key (dismiss/reopen).
103
+ */
104
+ private actionableCandidates;
105
+ /**
106
+ * Each company's held exact keys (spec §5.A step 4): one read of `role_permission` rows of the
107
+ * roles the companies own, raw keys (exact or wildcard) expanded per company with
108
+ * `expandGrantKeys`. The universe is the registry's keys plus the candidate keys, so an exact
109
+ * grant of a catalogued key always counts as held.
110
+ */
111
+ private heldKeys;
112
+ /**
113
+ * The dismissals that apply (spec §5.A step 5): a row whose `pad_activated_at` equals the key's
114
+ * current activation, compared in SQL. Any other row is stale and ignored. The dismisser is a
115
+ * name (user, else service account, else "Deleted user"), never the uuid.
116
+ */
117
+ private dismissals;
118
+ /**
119
+ * Lock the (company, key) dismissal row on `conn`, re-check that no company role holds the key
120
+ * under that lock (409; a grant may have landed since `authorizeTask`), then report whether the
121
+ * row is a dismissal for the key's current activation. Only the dismissal row is locked: the
122
+ * activation comparison is a plain read, so the catalog row the reconciler updates stays free.
123
+ */
124
+ private lockCurrentDismissal;
125
+ /**
126
+ * The audit row on the write's transaction (spec §5.A; Shield #6): `admin_action` under the
127
+ * task's company, names and the key label, never ids beyond the event's actor/tenant fields.
128
+ * An actor bound to a declared operation is attributed as in grant administration.
129
+ */
130
+ private auditChange;
131
+ /**
132
+ * One authz-pool transaction: `body` then commit; any failure rolls back. Identity errors pass
133
+ * through; every other failure (store, audit) is a 503 (`AuthorizationUnavailableError`).
134
+ */
135
+ private inTransaction;
136
+ /** Run reads, mapping any non-domain failure to `AuthorizationUnavailableError` (503). */
137
+ private store;
138
+ /** The registry's exact-key universe (as `GrantCeiling`). */
139
+ private registeredKeys;
140
+ /** The stored label, else the generated one (as the access-change notifier). */
141
+ private labelOf;
142
+ }
143
+ //# sourceMappingURL=permission-announcement.service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"permission-announcement.service.d.ts","sourceRoot":"","sources":["../../../src/features/permission-announcement/permission-announcement.service.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,389 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PermissionAnnouncementService = exports.ANNOUNCEMENT_MAX_LIMIT = exports.ANNOUNCEMENT_DEFAULT_LIMIT = exports.ANNOUNCEMENT_STATUSES = void 0;
4
+ const core_1 = require("@astram/core");
5
+ const engine_1 = require("@astram/engine");
6
+ const grant_ceiling_js_1 = require("../authz/grant-ceiling.js");
7
+ const db_permission_facts_adapter_js_1 = require("../authz/db-permission-facts.adapter.js");
8
+ const mariadb_catalog_store_js_1 = require("../authz/mariadb-catalog-store.js");
9
+ const access_change_notifier_js_1 = require("../authz/access-change-notifier.js");
10
+ const http_js_1 = require("../shared/http.js");
11
+ const identity_errors_js_1 = require("../shared/identity-errors.js");
12
+ const schema_js_1 = require("../../schema.js");
13
+ /** Task status (spec §10). */
14
+ exports.ANNOUNCEMENT_STATUSES = ['open', 'dismissed'];
15
+ /** Paging bounds (spec §3). */
16
+ exports.ANNOUNCEMENT_DEFAULT_LIMIT = 20;
17
+ exports.ANNOUNCEMENT_MAX_LIMIT = 100;
18
+ /** Shown for a dismisser who no longer exists (never the uuid; data-safety rule). */
19
+ const DELETED_ACTOR = 'Deleted user';
20
+ /** The one 404 message for every unreachable task (no existence leak, no ids). */
21
+ const TASK_NOT_FOUND = 'Permission announcement not found.';
22
+ /** 409 for a key a company role already holds: the task does not exist, so nothing to review. */
23
+ const heldConflict = () => new identity_errors_js_1.IdentityConflictError('A role in this organisation already holds this permission; there is nothing to review.');
24
+ /**
25
+ * The key's activation as one SQL expression over `<authz>.permission p`. `perm_last_activated_at`
26
+ * is stamped with first seen by the reconciler's insert; the COALESCE only covers a hand-edited row
27
+ * where it is NULL (the dismissal column is NOT NULL), so that row can still be dismissed.
28
+ */
29
+ const ACTIVATION = 'COALESCE(p.perm_last_activated_at, p.perm_first_seen_at)';
30
+ /** Map a uuid to a case-insensitive map key (stored uuids may differ in case only). */
31
+ const idKey = (uuid) => uuid.toLowerCase();
32
+ /** Identity errors pass through a store failure mapping untouched. */
33
+ function isDomainError(err) {
34
+ return (err instanceof identity_errors_js_1.IdentityNotFoundError ||
35
+ err instanceof identity_errors_js_1.IdentityConflictError ||
36
+ err instanceof identity_errors_js_1.IdentityBadRequestError ||
37
+ err instanceof identity_errors_js_1.IdentityForbiddenError);
38
+ }
39
+ class PermissionAnnouncementService {
40
+ pool;
41
+ ceiling;
42
+ registry;
43
+ audit;
44
+ schemas;
45
+ /**
46
+ * @param pool The ONE shared authz pool (reaches `<app>`, `<authz>`, `<identity>`, `<audit>`).
47
+ * @param ceiling The ADR-0029 grant ceiling: a non-root caller sees only keys it could grant.
48
+ * @param registry The shared record-authorization registry (wildcard expansion universe, labels).
49
+ * @param audit Written on the dismissal's own transaction (fail closed, Shield #6).
50
+ * @param schemas Resolved framework names (absent ⇒ resolved from the namespace, once, here).
51
+ */
52
+ constructor(pool, ceiling, registry, audit, schemas = (0, schema_js_1.frameworkSchemasFromEnv)()) {
53
+ this.pool = pool;
54
+ this.ceiling = ceiling;
55
+ this.registry = registry;
56
+ this.audit = audit;
57
+ this.schemas = schemas;
58
+ }
59
+ /** The caller's actionable tasks (spec §5.A `list`). */
60
+ async list(actor, query) {
61
+ return this.store('permission announcements', async () => {
62
+ const { companies, all } = await this.companiesFor(actor, query.companyUuid);
63
+ const candidates = await this.actionableCandidates(actor);
64
+ const rows = [];
65
+ if (companies.length > 0 && candidates.length > 0) {
66
+ const held = await this.heldKeys(companies, all, candidates.map((c) => c.key));
67
+ const dismissed = await this.dismissals(companies, all);
68
+ for (const company of companies) {
69
+ const heldHere = held.get(idKey(company.uuid)) ?? new Set();
70
+ for (const c of candidates) {
71
+ if (heldHere.has(c.key))
72
+ continue;
73
+ const d = dismissed.get(`${idKey(company.uuid)}\u0000${c.key}`);
74
+ const status = d ? 'dismissed' : 'open';
75
+ if (status !== query.status)
76
+ continue;
77
+ rows.push({
78
+ companyUuid: company.uuid,
79
+ companyName: company.name,
80
+ key: c.key,
81
+ label: c.label,
82
+ description: c.description,
83
+ caution: c.caution,
84
+ firstSeenAt: c.firstSeenAt,
85
+ firstSeenVersion: c.firstSeenVersion,
86
+ status,
87
+ ...(d ? { dismissedAt: d.dismissedAt, dismissedByName: d.dismissedByName } : {}),
88
+ });
89
+ }
90
+ }
91
+ }
92
+ rows.sort(compareRows);
93
+ const start = (query.page - 1) * query.limit;
94
+ return {
95
+ total: rows.length,
96
+ rows: rows.slice(start, start + query.limit),
97
+ page: query.page,
98
+ limit: query.limit,
99
+ };
100
+ });
101
+ }
102
+ /**
103
+ * "Reviewed, not needed" (spec §5.A `dismiss`). Idempotent: already dismissed for the key's
104
+ * current activation ⇒ no write, no audit. Held in the company ⇒ 409.
105
+ */
106
+ async dismiss(actor, companyUuid, key) {
107
+ const task = await this.authorizeTask(actor, companyUuid, key);
108
+ await this.inTransaction('permission announcement dismiss', async (conn) => {
109
+ const current = await this.lockCurrentDismissal(conn, task);
110
+ if (current)
111
+ return; // same activation already dismissed: nothing to write or audit
112
+ const res = (await conn.query(`INSERT INTO \`${this.schemas.identity}\`.\`permission_announcement_dismissal\`
113
+ (pad_company_uuid, pad_perm_key, pad_activated_at, pad_dismissed_by, pad_dismissed_at)
114
+ SELECT ?, p.perm_key, ${ACTIVATION}, ?, CURRENT_TIMESTAMP(3)
115
+ FROM \`${this.schemas.authz}\`.\`permission\` p
116
+ WHERE p.perm_key = ? AND p.perm_status = 'active' AND p.perm_first_seen_at IS NOT NULL
117
+ ON DUPLICATE KEY UPDATE
118
+ pad_activated_at = VALUES(pad_activated_at),
119
+ pad_dismissed_by = VALUES(pad_dismissed_by),
120
+ pad_dismissed_at = VALUES(pad_dismissed_at)`, [task.company.uuid, actor.id, task.candidate.key]));
121
+ // The key left the catalog (orphaned) between the check and the write.
122
+ if (Number(res?.affectedRows ?? 0) === 0)
123
+ throw new identity_errors_js_1.IdentityNotFoundError(TASK_NOT_FOUND);
124
+ await this.auditChange(conn, 'permission_announcement.dismissed', actor, task);
125
+ });
126
+ }
127
+ /**
128
+ * Undo a dismissal (spec §5.A `reopen`). Idempotent: no dismissal for the key's current
129
+ * activation ⇒ no write, no audit. A stale row (earlier activation) is not a dismissal and is
130
+ * left as is (the read ignores it).
131
+ */
132
+ async reopen(actor, companyUuid, key) {
133
+ const task = await this.authorizeTask(actor, companyUuid, key);
134
+ await this.inTransaction('permission announcement reopen', async (conn) => {
135
+ const current = await this.lockCurrentDismissal(conn, task);
136
+ if (!current)
137
+ return;
138
+ await conn.query(`DELETE FROM \`${this.schemas.identity}\`.\`permission_announcement_dismissal\`
139
+ WHERE pad_company_uuid = ? AND pad_perm_key = ?`, [task.company.uuid, task.candidate.key]);
140
+ await this.auditChange(conn, 'permission_announcement.reopened', actor, task);
141
+ });
142
+ }
143
+ // ── Authorization ─────────────────────────────────────────────────────────────────────────
144
+ /**
145
+ * The same authorization as `list` for one (company, key): company reachable (404), key a
146
+ * candidate (404) inside the caller's ceiling (404), and not held in the company (409; the task
147
+ * does not exist, so there is nothing to dismiss or reopen).
148
+ */
149
+ async authorizeTask(actor, companyUuid, key) {
150
+ return this.store('permission announcement', async () => {
151
+ const { companies } = await this.companiesFor(actor, companyUuid);
152
+ const company = companies[0];
153
+ if (!company)
154
+ throw new identity_errors_js_1.IdentityNotFoundError(TASK_NOT_FOUND);
155
+ const candidate = (await this.actionableCandidates(actor, key))[0];
156
+ if (!candidate)
157
+ throw new identity_errors_js_1.IdentityNotFoundError(TASK_NOT_FOUND);
158
+ const held = await this.heldKeys([company], false, [candidate.key]);
159
+ if (held.get(idKey(company.uuid))?.has(candidate.key))
160
+ throw heldConflict();
161
+ return { company, candidate };
162
+ });
163
+ }
164
+ /**
165
+ * The companies the caller acts in (spec §5.A step 1). Non-root: only the active tenant (a
166
+ * different `companyUuid` ⇒ 404, never trusted). Root: every company, or the one given (unknown
167
+ * ⇒ 404). `all` is true only for root without a filter (the held/dismissal reads then skip IN).
168
+ */
169
+ async companiesFor(actor, companyUuid) {
170
+ const isRoot = actor.isRoot === true;
171
+ if (!isRoot && companyUuid !== undefined && !(0, http_js_1.sameUuid)(companyUuid, actor.tenantId)) {
172
+ throw new identity_errors_js_1.IdentityNotFoundError(TASK_NOT_FOUND);
173
+ }
174
+ const target = isRoot ? companyUuid : actor.tenantId;
175
+ if (target === undefined) {
176
+ const rows = (await this.pool.query(`SELECT c_uuid, c_name FROM \`${this.schemas.app}\`.\`companies\``));
177
+ return { companies: rows.map((r) => ({ uuid: r.c_uuid, name: r.c_name })), all: true };
178
+ }
179
+ const rows = (await this.pool.query(`SELECT c_uuid, c_name FROM \`${this.schemas.app}\`.\`companies\` WHERE c_uuid = ? LIMIT 1`, [target]));
180
+ const row = rows[0];
181
+ if (!row)
182
+ throw new identity_errors_js_1.IdentityNotFoundError(TASK_NOT_FOUND);
183
+ return { companies: [{ uuid: row.c_uuid, name: row.c_name }], all: false };
184
+ }
185
+ /**
186
+ * Candidate keys (spec §5.A step 2: active, first seen set) inside the caller's ceiling (step 3:
187
+ * non-root keeps `coversKey(effectiveKeys(actor), registered, key)`; root keeps all). `onlyKey`
188
+ * narrows the read to one key (dismiss/reopen).
189
+ */
190
+ async actionableCandidates(actor, onlyKey) {
191
+ const rows = (await this.pool.query(`SELECT perm_key, perm_label, perm_description, perm_caution,
192
+ UNIX_TIMESTAMP(perm_first_seen_at) AS perm_first_seen_at, perm_first_seen_version
193
+ FROM \`${this.schemas.authz}\`.\`permission\`
194
+ WHERE perm_status = 'active' AND perm_first_seen_at IS NOT NULL${onlyKey === undefined ? '' : ' AND perm_key = ?'}`, onlyKey === undefined ? [] : [onlyKey]));
195
+ // A collation-equal match is not the key asked for (case or pad differences): exact only.
196
+ const matching = onlyKey === undefined ? rows : rows.filter((r) => r.perm_key === onlyKey);
197
+ let kept = matching;
198
+ if (actor.isRoot !== true) {
199
+ if (matching.length === 0)
200
+ return [];
201
+ const effective = await this.ceiling.effectiveKeys(actor);
202
+ const registered = this.registeredKeys();
203
+ kept = matching.filter((r) => (0, grant_ceiling_js_1.coversKey)(effective, registered, r.perm_key));
204
+ }
205
+ return kept.map((r) => ({
206
+ key: r.perm_key,
207
+ label: this.labelOf(r.perm_key, r.perm_label),
208
+ description: r.perm_description ?? null,
209
+ caution: r.perm_caution ?? null,
210
+ firstSeenAt: (0, mariadb_catalog_store_js_1.isoFromEpochSeconds)(r.perm_first_seen_at),
211
+ firstSeenVersion: r.perm_first_seen_version ?? null,
212
+ }));
213
+ }
214
+ /**
215
+ * Each company's held exact keys (spec §5.A step 4): one read of `role_permission` rows of the
216
+ * roles the companies own, raw keys (exact or wildcard) expanded per company with
217
+ * `expandGrantKeys`. The universe is the registry's keys plus the candidate keys, so an exact
218
+ * grant of a catalogued key always counts as held.
219
+ */
220
+ async heldKeys(companies, all, candidateKeys, query = (sql, params) => this.pool.query(sql, params)) {
221
+ const ids = companies.map((c) => c.uuid);
222
+ const where = all
223
+ ? 'r.r_owner_company_uuid IS NOT NULL'
224
+ : `r.r_owner_company_uuid IN (${ids.map(() => '?').join(', ')})`;
225
+ const rows = (await query(`SELECT r.r_owner_company_uuid AS company, rp.rp_perm_key AS perm_key
226
+ FROM \`${this.schemas.app}\`.\`roles\` r
227
+ JOIN \`${this.schemas.authz}\`.\`role_permission\` rp ON rp.rp_role_uuid = r.r_uuid
228
+ WHERE ${where}`, all ? [] : ids));
229
+ const rawByCompany = new Map();
230
+ for (const r of rows) {
231
+ const k = idKey(r.company);
232
+ const list = rawByCompany.get(k);
233
+ if (list)
234
+ list.push(r.perm_key);
235
+ else
236
+ rawByCompany.set(k, [r.perm_key]);
237
+ }
238
+ const universe = [...new Set([...this.registeredKeys(), ...candidateKeys])];
239
+ const out = new Map();
240
+ for (const [company, raw] of rawByCompany) {
241
+ out.set(company, new Set((0, db_permission_facts_adapter_js_1.expandGrantKeys)(raw, universe)));
242
+ }
243
+ return out;
244
+ }
245
+ /**
246
+ * The dismissals that apply (spec §5.A step 5): a row whose `pad_activated_at` equals the key's
247
+ * current activation, compared in SQL. Any other row is stale and ignored. The dismisser is a
248
+ * name (user, else service account, else "Deleted user"), never the uuid.
249
+ */
250
+ async dismissals(companies, all) {
251
+ const ids = companies.map((c) => c.uuid);
252
+ const where = all ? '' : `WHERE d.pad_company_uuid IN (${ids.map(() => '?').join(', ')})`;
253
+ const rows = (await this.pool.query(`SELECT d.pad_company_uuid AS company, d.pad_perm_key AS perm_key,
254
+ UNIX_TIMESTAMP(d.pad_dismissed_at) AS dismissed_at,
255
+ COALESCE(u.u_username, sa.sa_label) AS dismissed_by_name
256
+ FROM \`${this.schemas.identity}\`.\`permission_announcement_dismissal\` d
257
+ JOIN \`${this.schemas.authz}\`.\`permission\` p
258
+ ON p.perm_key = d.pad_perm_key AND d.pad_activated_at = ${ACTIVATION}
259
+ LEFT JOIN \`${this.schemas.app}\`.\`users\` u ON u.u_uuid = d.pad_dismissed_by
260
+ LEFT JOIN \`${this.schemas.app}\`.\`service_account\` sa ON sa.sa_id = d.pad_dismissed_by
261
+ ${where}`, all ? [] : ids));
262
+ const out = new Map();
263
+ for (const r of rows) {
264
+ out.set(`${idKey(r.company)}\u0000${r.perm_key}`, {
265
+ dismissedAt: (0, mariadb_catalog_store_js_1.isoFromEpochSeconds)(r.dismissed_at),
266
+ dismissedByName: r.dismissed_by_name ?? DELETED_ACTOR,
267
+ });
268
+ }
269
+ return out;
270
+ }
271
+ // ── Writes ────────────────────────────────────────────────────────────────────────────────
272
+ /**
273
+ * Lock the (company, key) dismissal row on `conn`, re-check that no company role holds the key
274
+ * under that lock (409; a grant may have landed since `authorizeTask`), then report whether the
275
+ * row is a dismissal for the key's current activation. Only the dismissal row is locked: the
276
+ * activation comparison is a plain read, so the catalog row the reconciler updates stays free.
277
+ */
278
+ async lockCurrentDismissal(conn, task) {
279
+ const { company, candidate } = task;
280
+ await conn.query(`SELECT pad_perm_key
281
+ FROM \`${this.schemas.identity}\`.\`permission_announcement_dismissal\`
282
+ WHERE pad_company_uuid = ? AND pad_perm_key = ?
283
+ FOR UPDATE`, [company.uuid, candidate.key]);
284
+ const held = await this.heldKeys([company], false, [candidate.key], (sql, params) => conn.query(sql, params));
285
+ if (held.get(idKey(company.uuid))?.has(candidate.key))
286
+ throw heldConflict();
287
+ const rows = (await conn.query(`SELECT (d.pad_activated_at = ${ACTIVATION}) AS is_current
288
+ FROM \`${this.schemas.identity}\`.\`permission_announcement_dismissal\` d
289
+ LEFT JOIN \`${this.schemas.authz}\`.\`permission\` p ON p.perm_key = d.pad_perm_key
290
+ WHERE d.pad_company_uuid = ? AND d.pad_perm_key = ?`, [company.uuid, candidate.key]));
291
+ const v = rows[0]?.is_current;
292
+ return v !== undefined && v !== null && Number(v) === 1;
293
+ }
294
+ /**
295
+ * The audit row on the write's transaction (spec §5.A; Shield #6): `admin_action` under the
296
+ * task's company, names and the key label, never ids beyond the event's actor/tenant fields.
297
+ * An actor bound to a declared operation is attributed as in grant administration.
298
+ */
299
+ async auditChange(conn, action, actor, task) {
300
+ const op = (0, engine_1.operationOf)(actor);
301
+ const operationAttributes = !op
302
+ ? {}
303
+ : { operation: op.operationKey, step: op.stepKey, onBehalfOf: op.onBehalfOf };
304
+ await this.audit.record({
305
+ type: 'admin_action',
306
+ occurredAtMs: Date.now(),
307
+ actorId: actor.id,
308
+ tenantId: task.company.uuid,
309
+ action: { key: action },
310
+ attributes: {
311
+ eventType: 'permission_announcement',
312
+ key: task.candidate.key,
313
+ label: task.candidate.label,
314
+ company: task.company.name,
315
+ firstSeenVersion: task.candidate.firstSeenVersion,
316
+ ...operationAttributes,
317
+ },
318
+ }, (sql, params) => conn.query(sql, params));
319
+ }
320
+ /**
321
+ * One authz-pool transaction: `body` then commit; any failure rolls back. Identity errors pass
322
+ * through; every other failure (store, audit) is a 503 (`AuthorizationUnavailableError`).
323
+ */
324
+ async inTransaction(what, body) {
325
+ let conn;
326
+ try {
327
+ conn = (await this.pool.getConnection());
328
+ }
329
+ catch (cause) {
330
+ throw new core_1.AuthorizationUnavailableError(`${what} failed`, cause);
331
+ }
332
+ try {
333
+ await conn.beginTransaction();
334
+ await body(conn);
335
+ await conn.commit();
336
+ }
337
+ catch (cause) {
338
+ try {
339
+ await conn.rollback();
340
+ }
341
+ catch {
342
+ // The connection is released below; the original failure is what the caller sees.
343
+ }
344
+ if (isDomainError(cause))
345
+ throw cause;
346
+ throw new core_1.AuthorizationUnavailableError(`${what} failed`, cause);
347
+ }
348
+ finally {
349
+ await conn.release();
350
+ }
351
+ }
352
+ // ── Helpers ───────────────────────────────────────────────────────────────────────────────
353
+ /** Run reads, mapping any non-domain failure to `AuthorizationUnavailableError` (503). */
354
+ async store(what, read) {
355
+ try {
356
+ return await read();
357
+ }
358
+ catch (cause) {
359
+ if (isDomainError(cause) || cause instanceof core_1.AuthorizationUnavailableError)
360
+ throw cause;
361
+ throw new core_1.AuthorizationUnavailableError(`${what} unavailable`, cause);
362
+ }
363
+ }
364
+ /** The registry's exact-key universe (as `GrantCeiling`). */
365
+ registeredKeys() {
366
+ return this.registry.listActions().map((a) => a.permissionKey);
367
+ }
368
+ /** The stored label, else the generated one (as the access-change notifier). */
369
+ labelOf(key, stored) {
370
+ return stored && stored !== key ? stored : (0, access_change_notifier_js_1.generatedPermissionLabel)(this.registry, key);
371
+ }
372
+ }
373
+ exports.PermissionAnnouncementService = PermissionAnnouncementService;
374
+ /** Spec §5.A step 6: company name, then first seen (newest first, unknown last), then label. */
375
+ function compareRows(a, b) {
376
+ const byCompany = a.companyName.localeCompare(b.companyName);
377
+ if (byCompany !== 0)
378
+ return byCompany;
379
+ if (a.companyUuid !== b.companyUuid)
380
+ return a.companyUuid < b.companyUuid ? -1 : 1;
381
+ const ta = a.firstSeenAt === null ? -Infinity : Date.parse(a.firstSeenAt);
382
+ const tb = b.firstSeenAt === null ? -Infinity : Date.parse(b.firstSeenAt);
383
+ if (ta !== tb)
384
+ return tb > ta ? 1 : -1;
385
+ const byLabel = a.label.localeCompare(b.label);
386
+ if (byLabel !== 0)
387
+ return byLabel;
388
+ return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
389
+ }