@astram/identity 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth-facts-keys.d.ts +3 -0
- package/dist/auth-facts-keys.d.ts.map +1 -0
- package/dist/auth-facts-keys.js +24 -0
- package/dist/cross-tenant-grant.object.d.ts +28 -0
- package/dist/cross-tenant-grant.object.d.ts.map +1 -0
- package/dist/cross-tenant-grant.object.js +64 -0
- package/dist/features/access/access-admin.access.d.ts +3 -0
- package/dist/features/access/access-admin.access.d.ts.map +1 -0
- package/dist/features/access/access-admin.access.js +27 -0
- package/dist/features/access/company-admin.access.d.ts +3 -0
- package/dist/features/access/company-admin.access.d.ts.map +1 -0
- package/dist/features/access/company-admin.access.js +31 -0
- package/dist/features/access/index.d.ts +15 -0
- package/dist/features/access/index.d.ts.map +1 -0
- package/dist/features/access/index.js +26 -0
- package/dist/features/access/user-management.access.d.ts +3 -0
- package/dist/features/access/user-management.access.d.ts.map +1 -0
- package/dist/features/access/user-management.access.js +35 -0
- package/dist/features/audit/audit-read.service.d.ts +92 -0
- package/dist/features/audit/audit-read.service.d.ts.map +1 -0
- package/dist/features/audit/audit-read.service.js +97 -0
- package/dist/features/audit/audit.controller.d.ts +35 -0
- package/dist/features/audit/audit.controller.d.ts.map +1 -0
- package/dist/features/audit/audit.controller.js +73 -0
- package/dist/features/audit/directory-label-resolver.d.ts +14 -0
- package/dist/features/audit/directory-label-resolver.d.ts.map +1 -0
- package/dist/features/audit/directory-label-resolver.js +46 -0
- package/dist/features/audit/mariadb-audit-reader.d.ts +46 -0
- package/dist/features/audit/mariadb-audit-reader.d.ts.map +1 -0
- package/dist/features/audit/mariadb-audit-reader.js +116 -0
- package/dist/features/authz/access-bundle-reconcile.job.d.ts +35 -0
- package/dist/features/authz/access-bundle-reconcile.job.d.ts.map +1 -0
- package/dist/features/authz/access-bundle-reconcile.job.js +84 -0
- package/dist/features/authz/access-bundle-store.d.ts +65 -0
- package/dist/features/authz/access-bundle-store.d.ts.map +1 -0
- package/dist/features/authz/access-bundle-store.js +154 -0
- package/dist/features/authz/catalog-bootstrap.provider.d.ts +28 -0
- package/dist/features/authz/catalog-bootstrap.provider.d.ts.map +1 -0
- package/dist/features/authz/catalog-bootstrap.provider.js +85 -0
- package/dist/features/authz/catalog-reconcile.job.d.ts +23 -0
- package/dist/features/authz/catalog-reconcile.job.d.ts.map +1 -0
- package/dist/features/authz/catalog-reconcile.job.js +47 -0
- package/dist/features/authz/db-permission-facts.adapter.d.ts +60 -0
- package/dist/features/authz/db-permission-facts.adapter.d.ts.map +1 -0
- package/dist/features/authz/db-permission-facts.adapter.js +116 -0
- package/dist/features/authz/grant-admin.controller.d.ts +96 -0
- package/dist/features/authz/grant-admin.controller.d.ts.map +1 -0
- package/dist/features/authz/grant-admin.controller.js +209 -0
- package/dist/features/authz/grant-admin.service.d.ts +107 -0
- package/dist/features/authz/grant-admin.service.d.ts.map +1 -0
- package/dist/features/authz/grant-admin.service.js +297 -0
- package/dist/features/authz/mariadb-catalog-store.d.ts +16 -0
- package/dist/features/authz/mariadb-catalog-store.d.ts.map +1 -0
- package/dist/features/authz/mariadb-catalog-store.js +44 -0
- package/dist/features/authz/permission-version.bumper.d.ts +47 -0
- package/dist/features/authz/permission-version.bumper.d.ts.map +1 -0
- package/dist/features/authz/permission-version.bumper.js +68 -0
- package/dist/features/authz/record-authorization.d.ts +31 -0
- package/dist/features/authz/record-authorization.d.ts.map +1 -0
- package/dist/features/authz/record-authorization.js +59 -0
- package/dist/features/authz/session-facts.reader.d.ts +41 -0
- package/dist/features/authz/session-facts.reader.d.ts.map +1 -0
- package/dist/features/authz/session-facts.reader.js +27 -0
- package/dist/features/company/company.controller.d.ts +37 -0
- package/dist/features/company/company.controller.d.ts.map +1 -0
- package/dist/features/company/company.controller.js +100 -0
- package/dist/features/company/company.service.d.ts +25 -0
- package/dist/features/company/company.service.d.ts.map +1 -0
- package/dist/features/company/company.service.js +53 -0
- package/dist/features/cross-tenant/cross-tenant-grant-read.service.d.ts +126 -0
- package/dist/features/cross-tenant/cross-tenant-grant-read.service.d.ts.map +1 -0
- package/dist/features/cross-tenant/cross-tenant-grant-read.service.js +276 -0
- package/dist/features/cross-tenant/cross-tenant-grant.controller.d.ts +65 -0
- package/dist/features/cross-tenant/cross-tenant-grant.controller.d.ts.map +1 -0
- package/dist/features/cross-tenant/cross-tenant-grant.controller.js +250 -0
- package/dist/features/cross-tenant/grant-authorize.d.ts +42 -0
- package/dist/features/cross-tenant/grant-authorize.d.ts.map +1 -0
- package/dist/features/cross-tenant/grant-authorize.js +17 -0
- package/dist/features/identity-feature.module.d.ts +26 -0
- package/dist/features/identity-feature.module.d.ts.map +1 -0
- package/dist/features/identity-feature.module.js +247 -0
- package/dist/features/membership/membership.controller.d.ts +77 -0
- package/dist/features/membership/membership.controller.d.ts.map +1 -0
- package/dist/features/membership/membership.controller.js +174 -0
- package/dist/features/membership/membership.service.d.ts +73 -0
- package/dist/features/membership/membership.service.d.ts.map +1 -0
- package/dist/features/membership/membership.service.js +173 -0
- package/dist/features/membership/posting-version-bump.d.ts +10 -0
- package/dist/features/membership/posting-version-bump.d.ts.map +1 -0
- package/dist/features/membership/posting-version-bump.js +4 -0
- package/dist/features/membership/posting.service.d.ts +59 -0
- package/dist/features/membership/posting.service.d.ts.map +1 -0
- package/dist/features/membership/posting.service.js +181 -0
- package/dist/features/org-structure/org-catalog.controller.d.ts +14 -0
- package/dist/features/org-structure/org-catalog.controller.d.ts.map +1 -0
- package/dist/features/org-structure/org-catalog.controller.js +62 -0
- package/dist/features/org-structure/org-catalog.service.d.ts +19 -0
- package/dist/features/org-structure/org-catalog.service.d.ts.map +1 -0
- package/dist/features/org-structure/org-catalog.service.js +53 -0
- package/dist/features/role/role.controller.d.ts +37 -0
- package/dist/features/role/role.controller.d.ts.map +1 -0
- package/dist/features/role/role.controller.js +104 -0
- package/dist/features/role/role.service.d.ts +24 -0
- package/dist/features/role/role.service.d.ts.map +1 -0
- package/dist/features/role/role.service.js +53 -0
- package/dist/features/shared/http.d.ts +15 -0
- package/dist/features/shared/http.d.ts.map +1 -0
- package/dist/features/shared/http.js +25 -0
- package/dist/features/shared/list-query.d.ts +44 -0
- package/dist/features/shared/list-query.d.ts.map +1 -0
- package/dist/features/shared/list-query.js +53 -0
- package/dist/features/shared/named-option.d.ts +6 -0
- package/dist/features/shared/named-option.d.ts.map +1 -0
- package/dist/features/shared/named-option.js +2 -0
- package/dist/features/shared/record-resolver.d.ts +11 -0
- package/dist/features/shared/record-resolver.d.ts.map +1 -0
- package/dist/features/shared/record-resolver.js +32 -0
- package/dist/features/user/user.controller.d.ts +39 -0
- package/dist/features/user/user.controller.d.ts.map +1 -0
- package/dist/features/user/user.controller.js +104 -0
- package/dist/features/user/user.service.d.ts +26 -0
- package/dist/features/user/user.service.d.ts.map +1 -0
- package/dist/features/user/user.service.js +53 -0
- package/dist/index.d.ts +34 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +66 -33
- package/dist/objects.d.ts +13 -0
- package/dist/objects.d.ts.map +1 -0
- package/dist/objects.js +20 -0
- package/dist/shared-resource.object.d.ts +28 -0
- package/dist/shared-resource.object.d.ts.map +1 -0
- package/dist/shared-resource.object.js +63 -0
- package/migrations/1710000000010-CreateAuditLog.cjs +42 -0
- package/migrations/1710000000011-CreateCrossTenantGrant.cjs +66 -0
- package/migrations/1710000000012-SeedCrossTenantGrantPerms.cjs +86 -0
- package/migrations/1710000000013-CreateSharedResource.cjs +30 -0
- package/migrations/1710000000099-SeedDevIdentity.cjs +130 -0
- package/migrations/1710000000100-SeedDevTenantBAndRoot.cjs +212 -0
- package/package.json +5 -4
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DirectoryLabelResolver = void 0;
|
|
4
|
+
const APP = 'shield_app';
|
|
5
|
+
/**
|
|
6
|
+
* The id->human-label chokepoint (ADR-0012, design §2.2). A deleted/unknown
|
|
7
|
+
* entity resolves to a FALLBACK label (never the id); a directory OUTAGE throws
|
|
8
|
+
* (fail-closed — a transient must not masquerade as a deletion).
|
|
9
|
+
*/
|
|
10
|
+
class DirectoryLabelResolver {
|
|
11
|
+
db;
|
|
12
|
+
constructor(db) {
|
|
13
|
+
this.db = db;
|
|
14
|
+
}
|
|
15
|
+
async resolve(kind, id, _tenantId) {
|
|
16
|
+
if (!id)
|
|
17
|
+
return this.fallback(kind);
|
|
18
|
+
switch (kind) {
|
|
19
|
+
case 'principal': {
|
|
20
|
+
const rows = (await this.db.query(`SELECT u_username AS label FROM \`${APP}\`.\`users\` WHERE u_uuid = ? LIMIT 1`, [id]));
|
|
21
|
+
return rows[0]?.label ?? this.fallback(kind);
|
|
22
|
+
}
|
|
23
|
+
case 'tenant': {
|
|
24
|
+
const rows = (await this.db.query(`SELECT c_name AS label FROM \`${APP}\`.\`companies\` WHERE c_uuid = ? LIMIT 1`, [id]));
|
|
25
|
+
return rows[0]?.label ?? this.fallback(kind);
|
|
26
|
+
}
|
|
27
|
+
case 'role': {
|
|
28
|
+
const rows = (await this.db.query(`SELECT r_name AS label FROM \`${APP}\`.\`roles\` WHERE r_uuid = ? LIMIT 1`, [id]));
|
|
29
|
+
return rows[0]?.label ?? this.fallback(kind);
|
|
30
|
+
}
|
|
31
|
+
default:
|
|
32
|
+
// service_account / object_type / permission not shown in v1 columns → safe fallback.
|
|
33
|
+
return '(unknown)';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
fallback(kind) {
|
|
37
|
+
if (kind === 'principal')
|
|
38
|
+
return 'Deleted user';
|
|
39
|
+
if (kind === 'tenant')
|
|
40
|
+
return 'Deleted tenant';
|
|
41
|
+
if (kind === 'role')
|
|
42
|
+
return 'Deleted role';
|
|
43
|
+
return '(unknown)';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
exports.DirectoryLabelResolver = DirectoryLabelResolver;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { AuditReader, AuditEvent } from '@astram/core';
|
|
2
|
+
export interface AuditQueryable {
|
|
3
|
+
query(sql: string, params?: readonly unknown[]): Promise<unknown>;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* The astram3 AuditReader over `shield_audit.audit_log` (design §2.2). Newest-first,
|
|
7
|
+
* tenant-scoped unless the root sentinel `'*'` is passed (the service sets that
|
|
8
|
+
* ONLY for a root). `[]` on no match (normal); THROWS on a store failure
|
|
9
|
+
* (fail-closed — never masks an outage as empty).
|
|
10
|
+
*/
|
|
11
|
+
export declare class MariaDbAuditReader implements AuditReader {
|
|
12
|
+
private readonly db;
|
|
13
|
+
private readonly schema;
|
|
14
|
+
constructor(db: AuditQueryable, opts?: {
|
|
15
|
+
schema?: string;
|
|
16
|
+
});
|
|
17
|
+
read(query: {
|
|
18
|
+
tenantId: string;
|
|
19
|
+
type?: AuditEvent['type'];
|
|
20
|
+
actorId?: string;
|
|
21
|
+
limit?: number;
|
|
22
|
+
offset?: number;
|
|
23
|
+
before?: string;
|
|
24
|
+
}): Promise<readonly AuditEvent[]>;
|
|
25
|
+
/**
|
|
26
|
+
* `SELECT COUNT(*)` over the SAME tenant/type/actorId scope `read` applies — used by the
|
|
27
|
+
* viewer for numbered (offset+total) pagination. Deliberately excludes `before` and any
|
|
28
|
+
* limit/offset (a total must not be windowed). Tenant scope is built by the SAME helper
|
|
29
|
+
* `read` uses, so the count can never widen past the page's tenant boundary (fail-closed
|
|
30
|
+
* isolation — see security invariant #4). THROWS on a store failure (fail-closed).
|
|
31
|
+
*/
|
|
32
|
+
count(query: {
|
|
33
|
+
tenantId: string;
|
|
34
|
+
type?: string;
|
|
35
|
+
actorId?: string;
|
|
36
|
+
}): Promise<number>;
|
|
37
|
+
/**
|
|
38
|
+
* Shared tenant/type/actorId scope-building for `read` and `count`. Both MUST resolve the
|
|
39
|
+
* same scope for the same query shape — a count that diverged from its page's WHERE would
|
|
40
|
+
* leak another tenant's total (invariant #4: tenant isolation). `before`/`limit`/`offset`
|
|
41
|
+
* are NOT part of this shared scope — they stay in `read` only.
|
|
42
|
+
*/
|
|
43
|
+
private buildScopeClause;
|
|
44
|
+
private toEvent;
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=mariadb-audit-reader.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mariadb-audit-reader.d.ts","sourceRoot":"","sources":["../../../src/features/audit/mariadb-audit-reader.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAE5D,MAAM,WAAW,cAAc;IAC7B,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACnE;AAgBD;;;;;GAKG;AACH,qBAAa,kBAAmB,YAAW,WAAW;IACpD,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAiB;IACpC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;gBAEpB,EAAE,EAAE,cAAc,EAAE,IAAI,GAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAO;IAKxD,IAAI,CAAC,KAAK,EAAE;QAChB,QAAQ,EAAE,MAAM,CAAC;QACjB,IAAI,CAAC,EAAE,UAAU,CAAC,MAAM,CAAC,CAAC;QAC1B,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,MAAM,CAAC,EAAE,MAAM,CAAC;KACjB,GAAG,OAAO,CAAC,SAAS,UAAU,EAAE,CAAC;IAgClC;;;;;;OAMG;IACG,KAAK,CAAC,KAAK,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,CAAC;IAY1F;;;;;OAKG;IACH,OAAO,CAAC,gBAAgB;IAwBxB,OAAO,CAAC,OAAO;CA+BhB"}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MariaDbAuditReader = void 0;
|
|
4
|
+
const DEFAULT_SCHEMA = 'shield_audit';
|
|
5
|
+
const ALL_TENANTS = '*';
|
|
6
|
+
/**
|
|
7
|
+
* The astram3 AuditReader over `shield_audit.audit_log` (design §2.2). Newest-first,
|
|
8
|
+
* tenant-scoped unless the root sentinel `'*'` is passed (the service sets that
|
|
9
|
+
* ONLY for a root). `[]` on no match (normal); THROWS on a store failure
|
|
10
|
+
* (fail-closed — never masks an outage as empty).
|
|
11
|
+
*/
|
|
12
|
+
class MariaDbAuditReader {
|
|
13
|
+
db;
|
|
14
|
+
schema;
|
|
15
|
+
constructor(db, opts = {}) {
|
|
16
|
+
this.db = db;
|
|
17
|
+
this.schema = opts.schema ?? DEFAULT_SCHEMA;
|
|
18
|
+
}
|
|
19
|
+
async read(query) {
|
|
20
|
+
const { where, params } = this.buildScopeClause(query);
|
|
21
|
+
if (query.before !== undefined) {
|
|
22
|
+
const beforeMs = Number(query.before);
|
|
23
|
+
if (Number.isFinite(beforeMs)) {
|
|
24
|
+
where.push('al_occurred_at_ms < ?');
|
|
25
|
+
params.push(beforeMs);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const whereSql = where.length > 0 ? ` WHERE ${where.join(' AND ')}` : '';
|
|
29
|
+
let limitOffsetSql = '';
|
|
30
|
+
if (query.limit !== undefined) {
|
|
31
|
+
params.push(query.limit);
|
|
32
|
+
limitOffsetSql = ' LIMIT ?';
|
|
33
|
+
if (query.offset !== undefined) {
|
|
34
|
+
params.push(query.offset);
|
|
35
|
+
limitOffsetSql += ' OFFSET ?';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const rows = (await this.db.query(`SELECT al_type, al_occurred_at_ms, al_actor_id, al_tenant_id,` +
|
|
39
|
+
` al_action_key, al_attributes_json` +
|
|
40
|
+
` FROM \`${this.schema}\`.\`audit_log\`` +
|
|
41
|
+
`${whereSql} ORDER BY al_occurred_at_ms DESC${limitOffsetSql}`, params));
|
|
42
|
+
return rows.map((r) => this.toEvent(r));
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* `SELECT COUNT(*)` over the SAME tenant/type/actorId scope `read` applies — used by the
|
|
46
|
+
* viewer for numbered (offset+total) pagination. Deliberately excludes `before` and any
|
|
47
|
+
* limit/offset (a total must not be windowed). Tenant scope is built by the SAME helper
|
|
48
|
+
* `read` uses, so the count can never widen past the page's tenant boundary (fail-closed
|
|
49
|
+
* isolation — see security invariant #4). THROWS on a store failure (fail-closed).
|
|
50
|
+
*/
|
|
51
|
+
async count(query) {
|
|
52
|
+
const { where, params } = this.buildScopeClause(query);
|
|
53
|
+
const whereSql = where.length > 0 ? ` WHERE ${where.join(' AND ')}` : '';
|
|
54
|
+
const rows = (await this.db.query(`SELECT COUNT(*) AS n FROM \`${this.schema}\`.\`audit_log\`${whereSql}`, params));
|
|
55
|
+
return Number(rows[0]?.n ?? 0);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Shared tenant/type/actorId scope-building for `read` and `count`. Both MUST resolve the
|
|
59
|
+
* same scope for the same query shape — a count that diverged from its page's WHERE would
|
|
60
|
+
* leak another tenant's total (invariant #4: tenant isolation). `before`/`limit`/`offset`
|
|
61
|
+
* are NOT part of this shared scope — they stay in `read` only.
|
|
62
|
+
*/
|
|
63
|
+
buildScopeClause(query) {
|
|
64
|
+
const where = [];
|
|
65
|
+
const params = [];
|
|
66
|
+
if (query.tenantId !== ALL_TENANTS) {
|
|
67
|
+
where.push('al_tenant_id = ?');
|
|
68
|
+
params.push(query.tenantId);
|
|
69
|
+
}
|
|
70
|
+
// `query.type` carries the ACTION-KEY filter from the service (al_type is always
|
|
71
|
+
// 'authentication'; we filter/label by al_action_key — design §3.2).
|
|
72
|
+
if (query.type !== undefined) {
|
|
73
|
+
where.push('al_action_key = ?');
|
|
74
|
+
params.push(query.type);
|
|
75
|
+
}
|
|
76
|
+
if (query.actorId !== undefined) {
|
|
77
|
+
where.push('al_actor_id = ?');
|
|
78
|
+
params.push(query.actorId);
|
|
79
|
+
}
|
|
80
|
+
return { where, params };
|
|
81
|
+
}
|
|
82
|
+
toEvent(r) {
|
|
83
|
+
let attributes = {};
|
|
84
|
+
const rawAttrs = r.al_attributes_json;
|
|
85
|
+
if (rawAttrs && typeof rawAttrs === 'object' && !Array.isArray(rawAttrs)) {
|
|
86
|
+
// The mariadb driver auto-parses a JSON column → already an object.
|
|
87
|
+
attributes = rawAttrs;
|
|
88
|
+
}
|
|
89
|
+
else if (typeof rawAttrs === 'string' && rawAttrs.length > 0) {
|
|
90
|
+
// Defensive: a driver/config that returns the raw JSON string.
|
|
91
|
+
try {
|
|
92
|
+
const parsed = JSON.parse(rawAttrs);
|
|
93
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
94
|
+
attributes = parsed;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
attributes = {};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// `action` is the framework `Action` type; the viewer only reads its key. The spec's
|
|
102
|
+
// first test asserts `events[0].action === 'mfa.recovery_used'` (a bare string), so set
|
|
103
|
+
// `action` to the action-key STRING here. (Design note: downstream AuditReadService reads
|
|
104
|
+
// `action.key` OR a bare string — Task 3 handles both. For THIS task, satisfy the test:
|
|
105
|
+
// set `action` to the raw action-key string.)
|
|
106
|
+
return {
|
|
107
|
+
type: r.al_type,
|
|
108
|
+
occurredAtMs: Number(r.al_occurred_at_ms),
|
|
109
|
+
actorId: r.al_actor_id,
|
|
110
|
+
tenantId: r.al_tenant_id,
|
|
111
|
+
...(r.al_action_key ? { action: r.al_action_key } : {}),
|
|
112
|
+
attributes,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
exports.MariaDbAuditReader = MariaDbAuditReader;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Pool } from 'mariadb';
|
|
2
|
+
import type { AccessBundleSpec } from '@astram/engine';
|
|
3
|
+
/** The outcome of one reconcile pass. */
|
|
4
|
+
export interface AccessBundleReconcileReport {
|
|
5
|
+
readonly added: number;
|
|
6
|
+
readonly updated: number;
|
|
7
|
+
readonly orphaned: number;
|
|
8
|
+
readonly skippedKeys: readonly string[];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Reconciles the developer-declared `AccessBundleSpec[]` (from
|
|
12
|
+
* `ENGINE_ALL_ACCESS_BUNDLES`) into `shield_authz.access_bundle` (+ `_permission`),
|
|
13
|
+
* mirroring `catalog-reconcile.job.ts`.
|
|
14
|
+
*
|
|
15
|
+
* Runs AFTER the permission catalog reconcile so the live catalog exists to validate
|
|
16
|
+
* against. For each spec: filter its `permissionKeys` down to keys that are ACTIVE in
|
|
17
|
+
* `shield_authz.permission`; `console.warn` the skipped (unknown) ones (fail-loud — a
|
|
18
|
+
* mis-declared bundle is a bug, not a silent ghost-grant); upsert the bundle + sync its
|
|
19
|
+
* validated keys. Then orphan-mark (NEVER delete) any bundle in the table whose
|
|
20
|
+
* `(module, key)` is not in the declared set.
|
|
21
|
+
*
|
|
22
|
+
* Timestamp is supplied by the caller (keeps the pass deterministic).
|
|
23
|
+
*/
|
|
24
|
+
export declare class AccessBundleReconcileJob {
|
|
25
|
+
private readonly pool;
|
|
26
|
+
private readonly bundles;
|
|
27
|
+
private readonly store;
|
|
28
|
+
private running;
|
|
29
|
+
constructor(pool: Pool, bundles: readonly AccessBundleSpec[]);
|
|
30
|
+
/** Idempotent single pass. Guarded so overlapping ticks don't double-run. */
|
|
31
|
+
runOnce(_nowIso: string): Promise<AccessBundleReconcileReport>;
|
|
32
|
+
/** The set of permission keys that are ACTIVE in the live catalog. */
|
|
33
|
+
private loadActivePermissionKeys;
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=access-bundle-reconcile.job.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"access-bundle-reconcile.job.d.ts","sourceRoot":"","sources":["../../../src/features/authz/access-bundle-reconcile.job.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAKvD,yCAAyC;AACzC,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;CACzC;AAED;;;;;;;;;;;;;GAaG;AACH,qBAAa,wBAAwB;IAKjC,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAL1B,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAoB;IAC1C,OAAO,CAAC,OAAO,CAAS;gBAGL,IAAI,EAAE,IAAI,EACV,OAAO,EAAE,SAAS,gBAAgB,EAAE;IAKvD,6EAA6E;IACvE,OAAO,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,2BAA2B,CAAC;IAuDpE,sEAAsE;YACxD,wBAAwB;CAMvC"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AccessBundleReconcileJob = void 0;
|
|
4
|
+
const access_bundle_store_js_1 = require("./access-bundle-store.js");
|
|
5
|
+
const AUTHZ = 'shield_authz';
|
|
6
|
+
/**
|
|
7
|
+
* Reconciles the developer-declared `AccessBundleSpec[]` (from
|
|
8
|
+
* `ENGINE_ALL_ACCESS_BUNDLES`) into `shield_authz.access_bundle` (+ `_permission`),
|
|
9
|
+
* mirroring `catalog-reconcile.job.ts`.
|
|
10
|
+
*
|
|
11
|
+
* Runs AFTER the permission catalog reconcile so the live catalog exists to validate
|
|
12
|
+
* against. For each spec: filter its `permissionKeys` down to keys that are ACTIVE in
|
|
13
|
+
* `shield_authz.permission`; `console.warn` the skipped (unknown) ones (fail-loud — a
|
|
14
|
+
* mis-declared bundle is a bug, not a silent ghost-grant); upsert the bundle + sync its
|
|
15
|
+
* validated keys. Then orphan-mark (NEVER delete) any bundle in the table whose
|
|
16
|
+
* `(module, key)` is not in the declared set.
|
|
17
|
+
*
|
|
18
|
+
* Timestamp is supplied by the caller (keeps the pass deterministic).
|
|
19
|
+
*/
|
|
20
|
+
class AccessBundleReconcileJob {
|
|
21
|
+
pool;
|
|
22
|
+
bundles;
|
|
23
|
+
store;
|
|
24
|
+
running = false;
|
|
25
|
+
constructor(pool, bundles) {
|
|
26
|
+
this.pool = pool;
|
|
27
|
+
this.bundles = bundles;
|
|
28
|
+
this.store = new access_bundle_store_js_1.AccessBundleStore(pool);
|
|
29
|
+
}
|
|
30
|
+
/** Idempotent single pass. Guarded so overlapping ticks don't double-run. */
|
|
31
|
+
async runOnce(_nowIso) {
|
|
32
|
+
if (this.running) {
|
|
33
|
+
return { added: 0, updated: 0, orphaned: 0, skippedKeys: [] };
|
|
34
|
+
}
|
|
35
|
+
this.running = true;
|
|
36
|
+
try {
|
|
37
|
+
const activeKeys = await this.loadActivePermissionKeys();
|
|
38
|
+
let added = 0;
|
|
39
|
+
let updated = 0;
|
|
40
|
+
const skippedKeys = [];
|
|
41
|
+
for (const spec of this.bundles) {
|
|
42
|
+
const known = [];
|
|
43
|
+
for (const key of spec.permissionKeys) {
|
|
44
|
+
if (activeKeys.has(key))
|
|
45
|
+
known.push(key);
|
|
46
|
+
else
|
|
47
|
+
skippedKeys.push(key);
|
|
48
|
+
}
|
|
49
|
+
const outcome = await this.store.upsertBundle(spec);
|
|
50
|
+
if (outcome === 'inserted')
|
|
51
|
+
added++;
|
|
52
|
+
else if (outcome === 'updated')
|
|
53
|
+
updated++;
|
|
54
|
+
const uuid = await this.store.resolveBundleUuid(spec.module, spec.key);
|
|
55
|
+
if (uuid)
|
|
56
|
+
await this.store.syncBundlePermissions(uuid, known);
|
|
57
|
+
}
|
|
58
|
+
// Orphan-mark bundles present in the table but no longer declared.
|
|
59
|
+
const declared = new Set(this.bundles.map((b) => `${b.module}:${b.key}`));
|
|
60
|
+
const present = await this.store.listBundles();
|
|
61
|
+
const toOrphan = present
|
|
62
|
+
.filter((b) => b.status === 'active' && !declared.has(`${b.module}:${b.key}`))
|
|
63
|
+
.map((b) => [b.module, b.key]);
|
|
64
|
+
await this.store.markOrphaned(toOrphan);
|
|
65
|
+
const orphaned = toOrphan.length;
|
|
66
|
+
if (skippedKeys.length > 0) {
|
|
67
|
+
console.warn(`[access-bundle] SKIPPED ${skippedKeys.length} unknown permission key(s) ` +
|
|
68
|
+
`(not in the active catalog): ${[...new Set(skippedKeys)].join(', ')}`);
|
|
69
|
+
}
|
|
70
|
+
console.log(`[access-bundle] reconcile: +${added} ~${updated} -${orphaned} ` +
|
|
71
|
+
`(${skippedKeys.length} skipped)`);
|
|
72
|
+
return { added, updated, orphaned, skippedKeys };
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
this.running = false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** The set of permission keys that are ACTIVE in the live catalog. */
|
|
79
|
+
async loadActivePermissionKeys() {
|
|
80
|
+
const rows = (await this.pool.query(`SELECT perm_key FROM \`${AUTHZ}\`.\`permission\` WHERE perm_status = 'active'`));
|
|
81
|
+
return new Set(rows.map((r) => r.perm_key));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
exports.AccessBundleReconcileJob = AccessBundleReconcileJob;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Pool } from 'mariadb';
|
|
2
|
+
import type { AccessBundleSpec } from '@astram/engine';
|
|
3
|
+
/** A row from `shield_authz.access_bundle`, resolved to camelCase. */
|
|
4
|
+
export interface AccessBundleRow {
|
|
5
|
+
readonly uuid: string;
|
|
6
|
+
readonly key: string;
|
|
7
|
+
readonly module: string;
|
|
8
|
+
readonly label: string;
|
|
9
|
+
readonly description: string | null;
|
|
10
|
+
readonly status: 'active' | 'orphaned';
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Raw-SQL (parameterized, NO ORM) CRUD over `shield_authz.access_bundle` +
|
|
14
|
+
* `access_bundle_permission`, mirroring `mariadb-catalog-store.ts`.
|
|
15
|
+
*
|
|
16
|
+
* All identity is resolved by BUSINESS KEY (`(ab_module, ab_key)`) — bundle UUIDs are
|
|
17
|
+
* generated per-DB and are NEVER hard-coded or assumed by the caller.
|
|
18
|
+
*/
|
|
19
|
+
export declare class AccessBundleStore {
|
|
20
|
+
private readonly pool;
|
|
21
|
+
constructor(pool: Pool);
|
|
22
|
+
/** All bundles (active + orphaned), for the reconciler's declared-vs-present diff. */
|
|
23
|
+
listBundles(): Promise<AccessBundleRow[]>;
|
|
24
|
+
/** Resolve a bundle's UUID by its business key. `null` when absent. */
|
|
25
|
+
resolveBundleUuid(module: string, key: string): Promise<string | null>;
|
|
26
|
+
/**
|
|
27
|
+
* Resolve an ACTIVE bundle (by its `key`, unique-per-module) to its atomic permission
|
|
28
|
+
* keys. Returns `null` when no active bundle matches the key (unknown / orphaned) so the
|
|
29
|
+
* caller can raise `BadKeyError`. Bundle `key` alone is the admin-facing identifier;
|
|
30
|
+
* there is exactly one active bundle per key across the identity module today, so the
|
|
31
|
+
* lookup is by `ab_key` + `ab_status='active'`.
|
|
32
|
+
*/
|
|
33
|
+
resolveActiveBundleKeys(bundleKey: string): Promise<readonly string[] | null>;
|
|
34
|
+
/**
|
|
35
|
+
* All ACTIVE bundles with their resolved atomic key sets, for the grant-admin list API.
|
|
36
|
+
* NEVER exposes `ab_uuid` — the admin surface identifies a bundle by its `key`.
|
|
37
|
+
*/
|
|
38
|
+
listActiveBundlesWithKeys(): Promise<Array<{
|
|
39
|
+
key: string;
|
|
40
|
+
module: string;
|
|
41
|
+
label: string;
|
|
42
|
+
description?: string;
|
|
43
|
+
permissionKeys: string[];
|
|
44
|
+
}>>;
|
|
45
|
+
/**
|
|
46
|
+
* Insert-or-update a bundle by `(ab_module, ab_key)`. Sets status back to 'active'
|
|
47
|
+
* (a re-declared bundle that had been orphaned is revived). Returns:
|
|
48
|
+
* - `'inserted'` — a new row was created,
|
|
49
|
+
* - `'updated'` — an existing row's label/description/status actually changed,
|
|
50
|
+
* - `'unchanged'` — the row already matched (idempotent no-op).
|
|
51
|
+
*/
|
|
52
|
+
upsertBundle(spec: AccessBundleSpec): Promise<'inserted' | 'updated' | 'unchanged'>;
|
|
53
|
+
/**
|
|
54
|
+
* Sync a bundle's atomic key set to exactly `keys`: delete rows not in the set,
|
|
55
|
+
* insert the missing ones. Idempotent — a re-sync with the same set is a no-op.
|
|
56
|
+
*/
|
|
57
|
+
syncBundlePermissions(bundleUuid: string, keys: readonly string[]): Promise<void>;
|
|
58
|
+
/**
|
|
59
|
+
* Mark bundles whose `(module, key)` is NOT in the declared set as orphaned
|
|
60
|
+
* (never deleted — a granted role may reference it). `modKeysNotDeclared` is the
|
|
61
|
+
* list of `[module, key]` pairs to orphan.
|
|
62
|
+
*/
|
|
63
|
+
markOrphaned(modKeysNotDeclared: ReadonlyArray<readonly [string, string]>): Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=access-bundle-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"access-bundle-store.d.ts","sourceRoot":"","sources":["../../../src/features/authz/access-bundle-store.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAIvD,sEAAsE;AACtE,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IACpC,QAAQ,CAAC,MAAM,EAAE,QAAQ,GAAG,UAAU,CAAC;CACxC;AAED;;;;;;GAMG;AACH,qBAAa,iBAAiB;IAChB,OAAO,CAAC,QAAQ,CAAC,IAAI;gBAAJ,IAAI,EAAE,IAAI;IAEvC,sFAAsF;IAChF,WAAW,IAAI,OAAO,CAAC,eAAe,EAAE,CAAC;IAsB/C,uEAAuE;IACjE,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAS5E;;;;;;OAMG;IACG,uBAAuB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,MAAM,EAAE,GAAG,IAAI,CAAC;IAuBnF;;;OAGG;IACG,yBAAyB,IAAI,OAAO,CACxC,KAAK,CAAC;QACJ,GAAG,EAAE,MAAM,CAAC;QACZ,MAAM,EAAE,MAAM,CAAC;QACf,KAAK,EAAE,MAAM,CAAC;QACd,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,cAAc,EAAE,MAAM,EAAE,CAAC;KAC1B,CAAC,CACH;IA4CD;;;;;;OAMG;IACG,YAAY,CAAC,IAAI,EAAE,gBAAgB,GAAG,OAAO,CAAC,UAAU,GAAG,SAAS,GAAG,WAAW,CAAC;IAuCzF;;;OAGG;IACG,qBAAqB,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IA8BvF;;;;OAIG;IACG,YAAY,CAAC,kBAAkB,EAAE,aAAa,CAAC,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;CAUhG"}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AccessBundleStore = void 0;
|
|
4
|
+
const AUTHZ = 'shield_authz';
|
|
5
|
+
/**
|
|
6
|
+
* Raw-SQL (parameterized, NO ORM) CRUD over `shield_authz.access_bundle` +
|
|
7
|
+
* `access_bundle_permission`, mirroring `mariadb-catalog-store.ts`.
|
|
8
|
+
*
|
|
9
|
+
* All identity is resolved by BUSINESS KEY (`(ab_module, ab_key)`) — bundle UUIDs are
|
|
10
|
+
* generated per-DB and are NEVER hard-coded or assumed by the caller.
|
|
11
|
+
*/
|
|
12
|
+
class AccessBundleStore {
|
|
13
|
+
pool;
|
|
14
|
+
constructor(pool) {
|
|
15
|
+
this.pool = pool;
|
|
16
|
+
}
|
|
17
|
+
/** All bundles (active + orphaned), for the reconciler's declared-vs-present diff. */
|
|
18
|
+
async listBundles() {
|
|
19
|
+
const rows = (await this.pool.query(`SELECT ab_uuid, ab_key, ab_module, ab_label, ab_description, ab_status
|
|
20
|
+
FROM \`${AUTHZ}\`.\`access_bundle\``));
|
|
21
|
+
return rows.map((r) => ({
|
|
22
|
+
uuid: r.ab_uuid,
|
|
23
|
+
key: r.ab_key,
|
|
24
|
+
module: r.ab_module,
|
|
25
|
+
label: r.ab_label,
|
|
26
|
+
description: r.ab_description,
|
|
27
|
+
status: r.ab_status === 'orphaned' ? 'orphaned' : 'active',
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
/** Resolve a bundle's UUID by its business key. `null` when absent. */
|
|
31
|
+
async resolveBundleUuid(module, key) {
|
|
32
|
+
const rows = (await this.pool.query(`SELECT ab_uuid FROM \`${AUTHZ}\`.\`access_bundle\`
|
|
33
|
+
WHERE ab_module = ? AND ab_key = ? LIMIT 1`, [module, key]));
|
|
34
|
+
return rows[0]?.ab_uuid ?? null;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Resolve an ACTIVE bundle (by its `key`, unique-per-module) to its atomic permission
|
|
38
|
+
* keys. Returns `null` when no active bundle matches the key (unknown / orphaned) so the
|
|
39
|
+
* caller can raise `BadKeyError`. Bundle `key` alone is the admin-facing identifier;
|
|
40
|
+
* there is exactly one active bundle per key across the identity module today, so the
|
|
41
|
+
* lookup is by `ab_key` + `ab_status='active'`.
|
|
42
|
+
*/
|
|
43
|
+
async resolveActiveBundleKeys(bundleKey) {
|
|
44
|
+
const rows = (await this.pool.query(`SELECT abp.abp_perm_key AS perm_key
|
|
45
|
+
FROM \`${AUTHZ}\`.\`access_bundle\` ab
|
|
46
|
+
JOIN \`${AUTHZ}\`.\`access_bundle_permission\` abp
|
|
47
|
+
ON abp.abp_bundle_uuid = ab.ab_uuid
|
|
48
|
+
WHERE ab.ab_key = ? AND ab.ab_status = 'active'
|
|
49
|
+
ORDER BY abp.abp_perm_key`, [bundleKey]));
|
|
50
|
+
if (rows.length === 0) {
|
|
51
|
+
// Distinguish "no such active bundle" from "active bundle with zero keys".
|
|
52
|
+
const exists = (await this.pool.query(`SELECT 1 FROM \`${AUTHZ}\`.\`access_bundle\`
|
|
53
|
+
WHERE ab_key = ? AND ab_status = 'active' LIMIT 1`, [bundleKey]));
|
|
54
|
+
if (exists.length === 0)
|
|
55
|
+
return null;
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
return rows.map((r) => r.perm_key);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* All ACTIVE bundles with their resolved atomic key sets, for the grant-admin list API.
|
|
62
|
+
* NEVER exposes `ab_uuid` — the admin surface identifies a bundle by its `key`.
|
|
63
|
+
*/
|
|
64
|
+
async listActiveBundlesWithKeys() {
|
|
65
|
+
const rows = (await this.pool.query(`SELECT ab.ab_key AS bundle_key, ab.ab_module AS module, ab.ab_label AS label,
|
|
66
|
+
ab.ab_description AS description, abp.abp_perm_key AS perm_key
|
|
67
|
+
FROM \`${AUTHZ}\`.\`access_bundle\` ab
|
|
68
|
+
LEFT JOIN \`${AUTHZ}\`.\`access_bundle_permission\` abp
|
|
69
|
+
ON abp.abp_bundle_uuid = ab.ab_uuid
|
|
70
|
+
WHERE ab.ab_status = 'active'
|
|
71
|
+
ORDER BY ab.ab_module, ab.ab_key, abp.abp_perm_key`));
|
|
72
|
+
const byKey = new Map();
|
|
73
|
+
for (const r of rows) {
|
|
74
|
+
let bundle = byKey.get(r.bundle_key);
|
|
75
|
+
if (!bundle) {
|
|
76
|
+
bundle = {
|
|
77
|
+
key: r.bundle_key,
|
|
78
|
+
module: r.module,
|
|
79
|
+
label: r.label,
|
|
80
|
+
permissionKeys: [],
|
|
81
|
+
...(r.description != null ? { description: r.description } : {}),
|
|
82
|
+
};
|
|
83
|
+
byKey.set(r.bundle_key, bundle);
|
|
84
|
+
}
|
|
85
|
+
if (r.perm_key != null)
|
|
86
|
+
bundle.permissionKeys.push(r.perm_key);
|
|
87
|
+
}
|
|
88
|
+
return [...byKey.values()];
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Insert-or-update a bundle by `(ab_module, ab_key)`. Sets status back to 'active'
|
|
92
|
+
* (a re-declared bundle that had been orphaned is revived). Returns:
|
|
93
|
+
* - `'inserted'` — a new row was created,
|
|
94
|
+
* - `'updated'` — an existing row's label/description/status actually changed,
|
|
95
|
+
* - `'unchanged'` — the row already matched (idempotent no-op).
|
|
96
|
+
*/
|
|
97
|
+
async upsertBundle(spec) {
|
|
98
|
+
const description = spec.description ?? null;
|
|
99
|
+
const existing = (await this.pool.query(`SELECT ab_uuid, ab_label, ab_description, ab_status
|
|
100
|
+
FROM \`${AUTHZ}\`.\`access_bundle\`
|
|
101
|
+
WHERE ab_module = ? AND ab_key = ? LIMIT 1`, [spec.module, spec.key]));
|
|
102
|
+
const cur = existing[0];
|
|
103
|
+
if (cur) {
|
|
104
|
+
const same = cur.ab_label === spec.label &&
|
|
105
|
+
(cur.ab_description ?? null) === description &&
|
|
106
|
+
cur.ab_status === 'active';
|
|
107
|
+
if (same)
|
|
108
|
+
return 'unchanged';
|
|
109
|
+
await this.pool.query(`UPDATE \`${AUTHZ}\`.\`access_bundle\`
|
|
110
|
+
SET ab_label = ?, ab_description = ?, ab_status = 'active'
|
|
111
|
+
WHERE ab_uuid = ?`, [spec.label, description, cur.ab_uuid]);
|
|
112
|
+
return 'updated';
|
|
113
|
+
}
|
|
114
|
+
await this.pool.query(`INSERT INTO \`${AUTHZ}\`.\`access_bundle\`
|
|
115
|
+
(ab_uuid, ab_key, ab_module, ab_label, ab_description, ab_status)
|
|
116
|
+
VALUES (UUID(), ?, ?, ?, ?, 'active')`, [spec.key, spec.module, spec.label, description]);
|
|
117
|
+
return 'inserted';
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Sync a bundle's atomic key set to exactly `keys`: delete rows not in the set,
|
|
121
|
+
* insert the missing ones. Idempotent — a re-sync with the same set is a no-op.
|
|
122
|
+
*/
|
|
123
|
+
async syncBundlePermissions(bundleUuid, keys) {
|
|
124
|
+
const existing = (await this.pool.query(`SELECT abp_perm_key FROM \`${AUTHZ}\`.\`access_bundle_permission\`
|
|
125
|
+
WHERE abp_bundle_uuid = ?`, [bundleUuid]));
|
|
126
|
+
const have = new Set(existing.map((r) => r.abp_perm_key));
|
|
127
|
+
const want = new Set(keys);
|
|
128
|
+
const toDelete = [...have].filter((k) => !want.has(k));
|
|
129
|
+
const toInsert = [...want].filter((k) => !have.has(k));
|
|
130
|
+
if (toDelete.length > 0) {
|
|
131
|
+
const ph = toDelete.map(() => '?').join(', ');
|
|
132
|
+
await this.pool.query(`DELETE FROM \`${AUTHZ}\`.\`access_bundle_permission\`
|
|
133
|
+
WHERE abp_bundle_uuid = ? AND abp_perm_key IN (${ph})`, [bundleUuid, ...toDelete]);
|
|
134
|
+
}
|
|
135
|
+
for (const key of toInsert) {
|
|
136
|
+
await this.pool.query(`INSERT INTO \`${AUTHZ}\`.\`access_bundle_permission\`
|
|
137
|
+
(abp_uuid, abp_bundle_uuid, abp_perm_key)
|
|
138
|
+
VALUES (UUID(), ?, ?)`, [bundleUuid, key]);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Mark bundles whose `(module, key)` is NOT in the declared set as orphaned
|
|
143
|
+
* (never deleted — a granted role may reference it). `modKeysNotDeclared` is the
|
|
144
|
+
* list of `[module, key]` pairs to orphan.
|
|
145
|
+
*/
|
|
146
|
+
async markOrphaned(modKeysNotDeclared) {
|
|
147
|
+
for (const [module, key] of modKeysNotDeclared) {
|
|
148
|
+
await this.pool.query(`UPDATE \`${AUTHZ}\`.\`access_bundle\`
|
|
149
|
+
SET ab_status = 'orphaned'
|
|
150
|
+
WHERE ab_module = ? AND ab_key = ?`, [module, key]);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
exports.AccessBundleStore = AccessBundleStore;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type OnApplicationBootstrap } from '@nestjs/common';
|
|
2
|
+
import { DiscoveryService, MetadataScanner, Reflector } from '@nestjs/core';
|
|
3
|
+
import type { ObjectType } from '@astram/engine';
|
|
4
|
+
import { CatalogReconcileJob } from './catalog-reconcile.job.js';
|
|
5
|
+
import { AccessBundleReconcileJob } from './access-bundle-reconcile.job.js';
|
|
6
|
+
/**
|
|
7
|
+
* Boot: read-only verifyGatable (fail-closed — a stranded/unclassified route aborts
|
|
8
|
+
* boot). Post-boot: kick the mutating reconcile once, then on an interval (single
|
|
9
|
+
* process = single runner in dev; a clustered deploy would gate this behind a leader
|
|
10
|
+
* lock — deferred). DISABLE_ALL_CRONS disables the interval (boot verify still runs).
|
|
11
|
+
*
|
|
12
|
+
* The mutating half (the `CatalogReconcileJob`, holding the latest report for
|
|
13
|
+
* /health/catalog) is supplied by DI via the module factory — the SAME instance the
|
|
14
|
+
* health controller injects — so verify, the post-boot pass, and the endpoint all share
|
|
15
|
+
* one job. This provider owns only the boot verify + the schedule.
|
|
16
|
+
*/
|
|
17
|
+
export declare class CatalogBootstrapProvider implements OnApplicationBootstrap {
|
|
18
|
+
private readonly discovery;
|
|
19
|
+
private readonly scanner;
|
|
20
|
+
private readonly reflector;
|
|
21
|
+
private readonly job;
|
|
22
|
+
private readonly accessBundleJob;
|
|
23
|
+
private readonly registrations;
|
|
24
|
+
private timer;
|
|
25
|
+
constructor(discovery: DiscoveryService, scanner: MetadataScanner, reflector: Reflector, job: CatalogReconcileJob, accessBundleJob: AccessBundleReconcileJob, registrations: readonly ObjectType[]);
|
|
26
|
+
onApplicationBootstrap(): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=catalog-bootstrap.provider.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"catalog-bootstrap.provider.d.ts","sourceRoot":"","sources":["../../../src/features/authz/catalog-bootstrap.provider.ts"],"names":[],"mappings":"AAIA,OAAO,EAAsB,KAAK,sBAAsB,EAAE,MAAM,gBAAgB,CAAC;AACjF,OAAO,EAAE,gBAAgB,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAO5E,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AACjE,OAAO,EAAE,wBAAwB,EAAE,MAAM,kCAAkC,CAAC;AAK5E;;;;;;;;;;GAUG;AACH,qBACa,wBAAyB,YAAW,sBAAsB;IAInE,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,eAAe;IAEhC,OAAO,CAAC,QAAQ,CAAC,aAAa;IAThC,OAAO,CAAC,KAAK,CAA+B;gBAGzB,SAAS,EAAE,gBAAgB,EAC3B,OAAO,EAAE,eAAe,EACxB,SAAS,EAAE,SAAS,EACpB,GAAG,EAAE,mBAAmB,EACxB,eAAe,EAAE,wBAAwB,EAEzC,aAAa,EAAE,SAAS,UAAU,EAAE;IAGjD,sBAAsB,IAAI,OAAO,CAAC,IAAI,CAAC;CA2B9C"}
|