@astram/identity 0.3.1 → 0.5.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/dist/company-membership.object.d.ts.map +1 -1
- package/dist/company-membership.object.js +3 -2
- package/dist/company.object.d.ts.map +1 -1
- package/dist/company.object.js +8 -3
- package/dist/features/access/index.d.ts.map +1 -1
- package/dist/features/access/index.js +1 -5
- package/dist/features/audit/audit-read.service.d.ts.map +1 -1
- package/dist/features/audit/audit-read.service.js +13 -6
- package/dist/features/audit/audit.controller.d.ts.map +1 -1
- package/dist/features/audit/audit.controller.js +2 -0
- package/dist/features/audit/mariadb-audit-reader.d.ts.map +1 -1
- package/dist/features/authz/access-bundle-reconcile.job.d.ts.map +1 -1
- package/dist/features/authz/catalog-reconcile.job.d.ts.map +1 -1
- package/dist/features/authz/catalog-reconcile.job.js +1 -3
- package/dist/features/authz/db-permission-facts.adapter.d.ts +24 -4
- package/dist/features/authz/db-permission-facts.adapter.d.ts.map +1 -1
- package/dist/features/authz/db-permission-facts.adapter.js +92 -5
- package/dist/features/authz/grant-admin.controller.d.ts +8 -5
- package/dist/features/authz/grant-admin.controller.d.ts.map +1 -1
- package/dist/features/authz/grant-admin.controller.js +22 -15
- package/dist/features/authz/grant-admin.service.d.ts +57 -11
- package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
- package/dist/features/authz/grant-admin.service.js +163 -85
- package/dist/features/authz/grant-ceiling.d.ts +111 -0
- package/dist/features/authz/grant-ceiling.d.ts.map +1 -0
- package/dist/features/authz/grant-ceiling.js +248 -0
- package/dist/features/authz/mariadb-catalog-store.d.ts.map +1 -1
- package/dist/features/authz/mariadb-catalog-store.js +15 -3
- package/dist/features/authz/permission-version.bumper.d.ts +45 -1
- package/dist/features/authz/permission-version.bumper.d.ts.map +1 -1
- package/dist/features/authz/permission-version.bumper.js +106 -16
- package/dist/features/authz/record-authorization.d.ts.map +1 -1
- package/dist/features/authz/role-binding.d.ts +36 -0
- package/dist/features/authz/role-binding.d.ts.map +1 -0
- package/dist/features/authz/role-binding.js +53 -0
- package/dist/features/company/company-hierarchy.service.d.ts +46 -0
- package/dist/features/company/company-hierarchy.service.d.ts.map +1 -0
- package/dist/features/company/company-hierarchy.service.js +100 -0
- package/dist/features/company/company.controller.d.ts +23 -5
- package/dist/features/company/company.controller.d.ts.map +1 -1
- package/dist/features/company/company.controller.js +47 -12
- package/dist/features/company/company.service.d.ts +28 -12
- package/dist/features/company/company.service.d.ts.map +1 -1
- package/dist/features/company/company.service.js +61 -14
- package/dist/features/cross-tenant/cross-tenant-grant.controller.d.ts.map +1 -1
- package/dist/features/cross-tenant/cross-tenant-grant.controller.js +2 -0
- package/dist/features/identity-feature.module.d.ts.map +1 -1
- package/dist/features/identity-feature.module.js +123 -6
- package/dist/features/membership/membership.controller.d.ts +6 -5
- package/dist/features/membership/membership.controller.d.ts.map +1 -1
- package/dist/features/membership/membership.controller.js +34 -22
- package/dist/features/membership/membership.service.d.ts +20 -14
- package/dist/features/membership/membership.service.d.ts.map +1 -1
- package/dist/features/membership/membership.service.js +62 -29
- package/dist/features/membership/posting-version-bump.d.ts +5 -0
- package/dist/features/membership/posting-version-bump.d.ts.map +1 -1
- package/dist/features/membership/posting.service.d.ts +20 -24
- package/dist/features/membership/posting.service.d.ts.map +1 -1
- package/dist/features/membership/posting.service.js +101 -64
- package/dist/features/org-structure/org-catalog.controller.d.ts.map +1 -1
- package/dist/features/org-structure/org-catalog.controller.js +2 -0
- package/dist/features/org-structure/org-catalog.service.d.ts.map +1 -1
- package/dist/features/org-structure/org-catalog.service.js +4 -1
- package/dist/features/policy/policy.controller.d.ts.map +1 -1
- package/dist/features/policy/policy.controller.js +2 -0
- package/dist/features/role/role.controller.d.ts +3 -2
- package/dist/features/role/role.controller.d.ts.map +1 -1
- package/dist/features/role/role.controller.js +15 -10
- package/dist/features/role/role.service.d.ts +16 -8
- package/dist/features/role/role.service.d.ts.map +1 -1
- package/dist/features/role/role.service.js +24 -10
- package/dist/features/scope/scope-closure.service.d.ts +44 -4
- package/dist/features/scope/scope-closure.service.d.ts.map +1 -1
- package/dist/features/scope/scope-closure.service.js +215 -82
- package/dist/features/service-account/service-account.controller.d.ts +63 -0
- package/dist/features/service-account/service-account.controller.d.ts.map +1 -0
- package/dist/features/service-account/service-account.controller.js +295 -0
- package/dist/features/service-account/service-account.service.d.ts +209 -0
- package/dist/features/service-account/service-account.service.d.ts.map +1 -0
- package/dist/features/service-account/service-account.service.js +528 -0
- package/dist/features/service-account/service-account.store.d.ts +91 -0
- package/dist/features/service-account/service-account.store.d.ts.map +1 -0
- package/dist/features/service-account/service-account.store.js +158 -0
- package/dist/features/share/share.controller.d.ts.map +1 -1
- package/dist/features/share/share.controller.js +2 -0
- package/dist/features/share/share.service.d.ts.map +1 -1
- package/dist/features/shared/identity-error.interceptor.d.ts +24 -0
- package/dist/features/shared/identity-error.interceptor.d.ts.map +1 -0
- package/dist/features/shared/identity-error.interceptor.js +82 -0
- package/dist/features/shared/identity-errors.d.ts +23 -0
- package/dist/features/shared/identity-errors.d.ts.map +1 -0
- package/dist/features/shared/identity-errors.js +41 -0
- package/dist/features/user/session-revocation-check.d.ts +11 -0
- package/dist/features/user/session-revocation-check.d.ts.map +1 -0
- package/dist/features/user/session-revocation-check.js +21 -0
- package/dist/features/user/user.controller.d.ts +11 -6
- package/dist/features/user/user.controller.d.ts.map +1 -1
- package/dist/features/user/user.controller.js +35 -17
- package/dist/features/user/user.service.d.ts +55 -14
- package/dist/features/user/user.service.d.ts.map +1 -1
- package/dist/features/user/user.service.js +143 -16
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/membership-posting.object.d.ts.map +1 -1
- package/dist/membership-posting.object.js +4 -2
- package/dist/objects.d.ts.map +1 -1
- package/dist/objects.js +14 -3
- package/dist/role.object.d.ts.map +1 -1
- package/dist/role.object.js +3 -0
- package/dist/service-account.object.d.ts +31 -0
- package/dist/service-account.object.d.ts.map +1 -0
- package/dist/service-account.object.js +75 -0
- package/dist/user.object.d.ts.map +1 -1
- package/dist/user.object.js +3 -0
- package/migrations/1710000000018-CreateServiceAccount.cjs +53 -0
- package/migrations/1710000000019-AddSessionMustReenroll.cjs +35 -0
- package/package.json +4 -4
|
@@ -16,6 +16,7 @@ const objects_js_1 = require("../objects.js");
|
|
|
16
16
|
const index_js_1 = require("./access/index.js");
|
|
17
17
|
const company_service_js_1 = require("./company/company.service.js");
|
|
18
18
|
const company_controller_js_1 = require("./company/company.controller.js");
|
|
19
|
+
const company_hierarchy_service_js_1 = require("./company/company-hierarchy.service.js");
|
|
19
20
|
const role_service_js_1 = require("./role/role.service.js");
|
|
20
21
|
const role_controller_js_1 = require("./role/role.controller.js");
|
|
21
22
|
const user_service_js_1 = require("./user/user.service.js");
|
|
@@ -30,13 +31,18 @@ const posting_version_bump_js_1 = require("./membership/posting-version-bump.js"
|
|
|
30
31
|
const permission_version_bumper_js_1 = require("./authz/permission-version.bumper.js");
|
|
31
32
|
const record_authorization_js_1 = require("./authz/record-authorization.js");
|
|
32
33
|
const db_permission_facts_adapter_js_1 = require("./authz/db-permission-facts.adapter.js");
|
|
34
|
+
const session_revocation_check_js_1 = require("./user/session-revocation-check.js");
|
|
33
35
|
const access_bundle_store_js_1 = require("./authz/access-bundle-store.js");
|
|
34
36
|
const grant_admin_service_js_1 = require("./authz/grant-admin.service.js");
|
|
35
37
|
const grant_admin_controller_js_1 = require("./authz/grant-admin.controller.js");
|
|
38
|
+
const grant_ceiling_js_1 = require("./authz/grant-ceiling.js");
|
|
36
39
|
const policy_service_js_1 = require("./policy/policy.service.js");
|
|
37
40
|
const policy_controller_js_1 = require("./policy/policy.controller.js");
|
|
38
41
|
const share_service_js_1 = require("./share/share.service.js");
|
|
39
42
|
const share_controller_js_1 = require("./share/share.controller.js");
|
|
43
|
+
const service_account_store_js_1 = require("./service-account/service-account.store.js");
|
|
44
|
+
const service_account_service_js_1 = require("./service-account/service-account.service.js");
|
|
45
|
+
const service_account_controller_js_1 = require("./service-account/service-account.controller.js");
|
|
40
46
|
const catalog_reconcile_job_js_1 = require("./authz/catalog-reconcile.job.js");
|
|
41
47
|
const access_bundle_reconcile_job_js_1 = require("./authz/access-bundle-reconcile.job.js");
|
|
42
48
|
const catalog_bootstrap_provider_js_1 = require("./authz/catalog-bootstrap.provider.js");
|
|
@@ -48,6 +54,10 @@ const audit_controller_js_1 = require("./audit/audit.controller.js");
|
|
|
48
54
|
const cross_tenant_grant_read_service_js_1 = require("./cross-tenant/cross-tenant-grant-read.service.js");
|
|
49
55
|
const cross_tenant_grant_controller_js_1 = require("./cross-tenant/cross-tenant-grant.controller.js");
|
|
50
56
|
const grant_authorize_js_1 = require("./cross-tenant/grant-authorize.js");
|
|
57
|
+
/** DI token of the provider that links the guard's Redis version cache to the bumper (ADR-0035). */
|
|
58
|
+
const IDENTITY_VERSION_PUBLISHER_LINK = Symbol('IDENTITY_VERSION_PUBLISHER_LINK');
|
|
59
|
+
/** DI token of the boot check that warns when a user disable cannot revoke sessions. */
|
|
60
|
+
const IDENTITY_SESSION_REVOCATION_CHECK = Symbol('IDENTITY_SESSION_REVOCATION_CHECK');
|
|
51
61
|
/** Map guard's env DbConfig → the engine's EngineDbConfig (adds the pool cap the engine needs). */
|
|
52
62
|
function engineDbFromEnv() {
|
|
53
63
|
const db = (0, guard_1.loadDbConfig)();
|
|
@@ -97,20 +107,46 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
|
|
|
97
107
|
const bumper = new permission_version_bumper_js_1.PermissionVersionBumper();
|
|
98
108
|
const postingVersionBump = {
|
|
99
109
|
bumpTenant: (tenantUuid) => bumper.bumpForTenant(tenantUuid, authzPool),
|
|
110
|
+
// A user disabled/re-enabled: every membership tenant (autocommit pool → published at once).
|
|
111
|
+
bumpForUser: (userUuid) => bumper.bumpForUser(userUuid, authzPool),
|
|
100
112
|
};
|
|
113
|
+
// The core ModuleRegistry the catalog reconcile job needs, built from the identity
|
|
114
|
+
// object registrations (guard's bridge reflects decorated classes → registry). Built BEFORE
|
|
115
|
+
// the grant-admin service so the GrantCeiling (ADR-0029) can share it as the key universe.
|
|
116
|
+
const identityRegistry = (0, guard_1.buildIdentityModuleRegistry)([...objects_js_1.identityObjects]);
|
|
101
117
|
// The grant-admin collaborators built at forRoot() over the shared pool (mirrors the
|
|
102
|
-
// postingVersionBump closure pattern above).
|
|
118
|
+
// postingVersionBump closure pattern above). GrantCeiling (ADR-0029) enforces the
|
|
119
|
+
// self-escalation ceiling: a non-root grantor may only grant a key they themselves hold —
|
|
120
|
+
// resolved over the SAME authz pool + registry the object-type gate uses (one source of
|
|
121
|
+
// truth for "what a principal holds"). ALWAYS wired here, so production always enforces.
|
|
103
122
|
const accessBundleStore = new access_bundle_store_js_1.AccessBundleStore(authzPool);
|
|
104
|
-
|
|
123
|
+
// The ceiling's key universe includes the consumer's `extraObjects` (the SAME registry shape
|
|
124
|
+
// `buildIdentityRecordAuthorization(pool, extraObjects)` gives the decision path): otherwise a
|
|
125
|
+
// role holding a consumer-type key could never be assigned or granted by a non-root admin who
|
|
126
|
+
// holds that key (review M5) — the security-hardening role-assignment ceiling makes that
|
|
127
|
+
// reachable from every posting edit. `rootIds` marks platform roots as never dominated.
|
|
128
|
+
const ceilingRegistry = (0, guard_1.buildIdentityModuleRegistry)([...objects_js_1.identityObjects, ...extraObjects]);
|
|
129
|
+
// rootIds is passed EXPLICITLY (review F5): `null` states "not configured". The dominance
|
|
130
|
+
// check still refuses any user flagged `u_is_platform_superuser`, so an unconfigured list
|
|
131
|
+
// never lets a sub-admin take over a platform administrator.
|
|
132
|
+
if (rootIds === undefined) {
|
|
133
|
+
new common_1.Logger('IdentityFeatureModule').warn('rootIds not passed to IdentityFeatureModule.forRoot — pass the same array as ' +
|
|
134
|
+
'ShieldModule.forRoot({ rootIds }). Platform administrators are still protected via ' +
|
|
135
|
+
'users.u_is_platform_superuser.');
|
|
136
|
+
}
|
|
137
|
+
const grantCeiling = new grant_ceiling_js_1.GrantCeiling(authzPool, ceilingRegistry, rootIds ?? null);
|
|
138
|
+
// Grant administration is audited on the grant's own transaction (D6, fail-closed).
|
|
139
|
+
const grantAdminAuditSink = new engine_1.MariaDbAuditSink({}); // shield_audit.audit_log defaults
|
|
140
|
+
const grantAdminService = new grant_admin_service_js_1.GrantAdminService(authzPool, bumper, accessBundleStore, grantCeiling, grantAdminAuditSink);
|
|
105
141
|
// Scope closure (Task 1.2 of the scoped-hierarchy program): the reconciler + node
|
|
106
142
|
// move/delete maintenance, over the SAME shared authzPool (it reaches BOTH shield_app —
|
|
107
143
|
// companies — and shield_authz — scope_closure). Reuses the SAME `bumper` instance so a
|
|
108
144
|
// moveNode's session-invalidation joins the move's transaction. reconcileClosure() is
|
|
109
145
|
// kicked post-boot by the CatalogBootstrapProvider (mirrors the catalog reconcile).
|
|
110
146
|
const scopeClosureService = new scope_closure_service_js_1.ScopeClosureService(authzPool, bumper);
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
const
|
|
147
|
+
// Company hierarchy (security hardening #2): the ONLY path that moves/deletes/attaches a company
|
|
148
|
+
// node — through the closure service above — audited on the move/delete transaction.
|
|
149
|
+
const companyHierarchyService = new company_hierarchy_service_js_1.CompanyHierarchyService(authzPool, scopeClosureService, new engine_1.MariaDbAuditSink({}));
|
|
114
150
|
// Audit viewer (Phase 5): the AuditReader + LabelResolver over the SAME shared authzPool
|
|
115
151
|
// (the reader queries shield_audit + the resolver/actor-lookup query shield_app, both
|
|
116
152
|
// reachable by that pool). `auditNameToId` is the actor-name -> uuid reverse lookup for the
|
|
@@ -292,6 +328,50 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
|
|
|
292
328
|
},
|
|
293
329
|
});
|
|
294
330
|
const shareService = new share_service_js_1.ShareService(recordShareWriter, auditLabels);
|
|
331
|
+
// ── SERVICE ACCOUNTS + API KEYS (ADR-0033, Phase 2) ──────────────────────────────────────
|
|
332
|
+
// The machine-identity admin surface: create/scope service accounts + issue/rotate/revoke
|
|
333
|
+
// their `ask_…` API keys. Self-wired here over the SAME shared authzPool (which reaches BOTH
|
|
334
|
+
// shield_app.service_account — the accounts — AND shield_authz.service_account_key — the hashed
|
|
335
|
+
// keys). The guard's MariaDbApiKeyStore + Argon2idPasswordHasher back the credential lifecycle;
|
|
336
|
+
// the service enforces own-tenant + the ADR-0029 ceiling (scope ⊆ mine, roles ⊆ mine).
|
|
337
|
+
const serviceAccountStore = new service_account_store_js_1.ServiceAccountStore(authzPool);
|
|
338
|
+
const serviceAccountKeyStore = new guard_1.MariaDbApiKeyStore(authzPool);
|
|
339
|
+
const serviceAccountHasher = new guard_1.Argon2idPasswordHasher();
|
|
340
|
+
// Best-effort audit → the SAME shield_audit sink pattern (a sink throw never fails the op).
|
|
341
|
+
const serviceAccountAuditSink = new engine_1.MariaDbAuditSink({}); // shield_audit.audit_log defaults
|
|
342
|
+
const serviceAccountAudit = {
|
|
343
|
+
record: async (event) => {
|
|
344
|
+
try {
|
|
345
|
+
await serviceAccountAuditSink.record({
|
|
346
|
+
type: 'admin_action',
|
|
347
|
+
occurredAtMs: event.occurredAtMs,
|
|
348
|
+
actorId: event.actorId,
|
|
349
|
+
tenantId: event.tenantId,
|
|
350
|
+
action: { key: event.action },
|
|
351
|
+
attributes: { ...event.attributes, eventType: 'service_account' },
|
|
352
|
+
}, (sql, params) => authzPool.query(sql, params));
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
// Best-effort — an audit failure must never fail a legitimate account/key operation.
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
// Resolve a principal id → username for the "created by" view (no raw uuid). Reuses the same
|
|
360
|
+
// shield_app.users lookup the audit actor filter uses (inverted). Unknown → null (view falls
|
|
361
|
+
// back to no name, never an id).
|
|
362
|
+
const resolveActorName = async (id) => {
|
|
363
|
+
const rows = (await authzPool.query('SELECT u_username AS name FROM `shield_app`.`users` WHERE u_uuid = ? LIMIT 1', [id]));
|
|
364
|
+
return rows[0]?.name ?? null;
|
|
365
|
+
};
|
|
366
|
+
const serviceAccountService = new service_account_service_js_1.ServiceAccountService(serviceAccountStore, serviceAccountKeyStore, serviceAccountHasher, serviceAccountAudit, resolveActorName,
|
|
367
|
+
// The ceiling's roles: in-effect postings (the same source as gate 5 and GrantCeiling).
|
|
368
|
+
(principalId, tenantId) => (0, db_permission_facts_adapter_js_1.readRoleIdsForTenant)(authzPool, principalId, tenantId));
|
|
369
|
+
// NOTE: the machine AUTH path (the `ServiceAuthenticator` behind `SHIELD_SERVICE_AUTHENTICATOR`)
|
|
370
|
+
// is provided by the guard's own AuthModule (under `auth: true`), NOT here — a token provided in
|
|
371
|
+
// this later, sibling feature module is invisible to the APP_GUARD in ShieldModule (that DI
|
|
372
|
+
// boundary made an identity-provided authenticator resolve to `undefined` → `ask_…` bearers
|
|
373
|
+
// 401'd). This module owns only the ACCOUNT ADMIN surface (store + service + controller); the
|
|
374
|
+
// guard owns authentication. (ADR-0033.)
|
|
295
375
|
return {
|
|
296
376
|
module: IdentityFeatureModule_1,
|
|
297
377
|
imports: [
|
|
@@ -332,6 +412,9 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
|
|
|
332
412
|
policy_controller_js_1.PolicyController,
|
|
333
413
|
// Owner shares (Phase 6) — `record-shares` collides with nothing. Appended last.
|
|
334
414
|
share_controller_js_1.ShareController,
|
|
415
|
+
// Service accounts (ADR-0033) — `service-accounts/*` collides with nothing; its static
|
|
416
|
+
// sub-paths (/keys, /keys/rotate, /keys/revoke) sit under `:id` and are method-distinct.
|
|
417
|
+
service_account_controller_js_1.ServiceAccountController,
|
|
335
418
|
],
|
|
336
419
|
providers: [
|
|
337
420
|
company_service_js_1.CompanyService,
|
|
@@ -342,9 +425,36 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
|
|
|
342
425
|
org_catalog_service_js_1.OrgCatalogService,
|
|
343
426
|
record_resolver_js_1.RecordResolverService,
|
|
344
427
|
{ provide: posting_version_bump_js_1.POSTING_VERSION_BUMP, useValue: postingVersionBump },
|
|
428
|
+
// ADR-0035 (GAPS #6): attach the guard's Redis permission-version cache to the ONE shared
|
|
429
|
+
// bumper, so every committed bump (postings, grants, hierarchy) is published to Redis,
|
|
430
|
+
// where gate 5 reads it. Provided by the auth surface under `auth: true` (through the
|
|
431
|
+
// @Global ShieldModule); absent → DB-only bumps, exactly as before.
|
|
432
|
+
// Phase 3 review P2: with auth on (the token binder is visible) but no session sweeper, a
|
|
433
|
+
// user disable cannot revoke sessions — warn at boot.
|
|
434
|
+
{
|
|
435
|
+
provide: IDENTITY_SESSION_REVOCATION_CHECK,
|
|
436
|
+
useFactory: (binder, sweeper) => (0, session_revocation_check_js_1.warnIfSessionsUnrevocable)(binder, sweeper, (m) => new common_1.Logger('IdentityFeatureModule').warn(m)),
|
|
437
|
+
inject: [
|
|
438
|
+
{ token: guard_1.SHIELD_SESSION_TOKEN_BINDER, optional: true },
|
|
439
|
+
{ token: guard_1.SHIELD_SESSION_SWEEPER, optional: true },
|
|
440
|
+
],
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
provide: IDENTITY_VERSION_PUBLISHER_LINK,
|
|
444
|
+
useFactory: (cache) => {
|
|
445
|
+
if (cache)
|
|
446
|
+
bumper.attachVersionPublisher(cache);
|
|
447
|
+
return cache !== null && cache !== undefined;
|
|
448
|
+
},
|
|
449
|
+
inject: [{ token: guard_1.SHIELD_PERMISSION_VERSION_CACHE, optional: true }],
|
|
450
|
+
},
|
|
345
451
|
// Grant-admin stack — all over the ONE shared authzPool.
|
|
346
452
|
{ provide: access_bundle_store_js_1.AccessBundleStore, useValue: accessBundleStore },
|
|
347
453
|
{ provide: grant_admin_service_js_1.GrantAdminService, useValue: grantAdminService },
|
|
454
|
+
// The ONE tenant-boundary authority (ADR-0029 ceiling + block A: role assignment, user
|
|
455
|
+
// dominance, role binding) — injected by RoleService, PostingService and UserService.
|
|
456
|
+
{ provide: grant_ceiling_js_1.GrantCeiling, useValue: grantCeiling },
|
|
457
|
+
{ provide: company_hierarchy_service_js_1.CompanyHierarchyService, useValue: companyHierarchyService },
|
|
348
458
|
// Audit read stack — built at forRoot() over the ONE shared authzPool, provided as a
|
|
349
459
|
// useValue (like GrantAdminService) so it carries the hand-built reader/resolver/lookup.
|
|
350
460
|
{ provide: audit_read_service_js_1.AuditReadService, useValue: auditReadService },
|
|
@@ -359,9 +469,16 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
|
|
|
359
469
|
// Owner shares (Phase 6) — the ShareService over the framework RecordShareWriter + the
|
|
360
470
|
// DirectoryLabelResolver (name resolution). useValue, all over the shared authzPool.
|
|
361
471
|
{ provide: share_service_js_1.ShareService, useValue: shareService },
|
|
472
|
+
// Service accounts (ADR-0033) — the ServiceAccountService over the shared authzPool +
|
|
473
|
+
// guard key-store/hasher, and the ServiceAuthenticator under the guard's token so the
|
|
474
|
+
// enforcement guard can authenticate `ask_…` bearers out-of-the-box. Both useValue.
|
|
475
|
+
{ provide: service_account_service_js_1.ServiceAccountService, useValue: serviceAccountService },
|
|
362
476
|
// The mutating half of auto-catalog: a single CatalogReconcileJob (post-boot pass +
|
|
363
477
|
// cron), over the shared pool + the identity registry.
|
|
364
|
-
{
|
|
478
|
+
{
|
|
479
|
+
provide: catalog_reconcile_job_js_1.CatalogReconcileJob,
|
|
480
|
+
useValue: new catalog_reconcile_job_js_1.CatalogReconcileJob(authzPool, identityRegistry),
|
|
481
|
+
},
|
|
365
482
|
// The bundle half: reconciles the merged AccessBundleSpec[] (ENGINE_ALL_ACCESS_BUNDLES,
|
|
366
483
|
// reflected by the engine from identityAccessBundles) into shield_authz.access_bundle.
|
|
367
484
|
{
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { MembershipService } from './membership.service.js';
|
|
2
2
|
import { PostingService, type PostingInput } from './posting.service.js';
|
|
3
3
|
import { RecordResolverService } from '../shared/record-resolver.js';
|
|
4
|
+
import { type AuthedRequest } from '../shared/http.js';
|
|
4
5
|
/** A membership card for the API, with EVERY uuid resolved to a display name. */
|
|
5
6
|
interface MembershipCardDto {
|
|
6
7
|
/** The membership id — used as a route param / react key, NEVER as a display field. */
|
|
@@ -39,7 +40,7 @@ export declare class MembershipController {
|
|
|
39
40
|
memberships: MembershipCardDto[];
|
|
40
41
|
}>;
|
|
41
42
|
/** Add the user to a company (creates an inactive membership card). */
|
|
42
|
-
addUserMembership(userUuid: string, body: {
|
|
43
|
+
addUserMembership(req: AuthedRequest, userUuid: string, body: {
|
|
43
44
|
companyUuid?: string;
|
|
44
45
|
}): Promise<{
|
|
45
46
|
id: import("@astram/engine").RecordId;
|
|
@@ -49,19 +50,19 @@ export declare class MembershipController {
|
|
|
49
50
|
* invariant); `status` is a plain field update. Activating requires the owning user so
|
|
50
51
|
* the invariant can deactivate the user's OTHER memberships.
|
|
51
52
|
*/
|
|
52
|
-
updateMembership(membershipUuid: string, body: {
|
|
53
|
+
updateMembership(req: AuthedRequest, membershipUuid: string, body: {
|
|
53
54
|
isActive?: boolean;
|
|
54
55
|
status?: string;
|
|
55
56
|
userUuid?: string;
|
|
56
57
|
}): Promise<{
|
|
57
58
|
success: boolean;
|
|
58
59
|
}>;
|
|
59
|
-
/** Remove a membership card (its postings cascade at the DB). */
|
|
60
|
-
removeMembership(membershipUuid: string): Promise<{
|
|
60
|
+
/** Remove a membership card (its postings cascade at the DB). A peer's needs dominance. */
|
|
61
|
+
removeMembership(req: AuthedRequest, membershipUuid: string): Promise<{
|
|
61
62
|
success: boolean;
|
|
62
63
|
}>;
|
|
63
64
|
/** Atomically replace a membership's full posting set (the diff sync). */
|
|
64
|
-
replacePostings(membershipUuid: string, body: {
|
|
65
|
+
replacePostings(req: AuthedRequest, membershipUuid: string, body: {
|
|
65
66
|
postings?: PostingInput[];
|
|
66
67
|
}): Promise<{
|
|
67
68
|
success: boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"membership.controller.d.ts","sourceRoot":"","sources":["../../../src/features/membership/membership.controller.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"membership.controller.d.ts","sourceRoot":"","sources":["../../../src/features/membership/membership.controller.ts"],"names":[],"mappings":"AAcA,OAAO,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAC5D,OAAO,EAAE,cAAc,EAAE,KAAK,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACzE,OAAO,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAC;AACrE,OAAO,EAAqB,KAAK,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAG1E,iFAAiF;AACjF,UAAU,iBAAiB;IACzB,uFAAuF;IACvF,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,OAAO,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,cAAc,EAAE,CAAC;CAC5B;AAED,4EAA4E;AAC5E,UAAU,cAAc;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,kFAAkF;IAClF,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;CAC/B;AAYD,qBAEa,oBAAoB;IAE7B,OAAO,CAAC,QAAQ,CAAC,WAAW;IAC5B,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,QAAQ;gBAFR,WAAW,EAAE,iBAAiB,EAC9B,QAAQ,EAAE,cAAc,EACxB,QAAQ,EAAE,qBAAqB;IAGlD;;;;;OAKG;IAGG,mBAAmB,CAAoB,QAAQ,EAAE,MAAM;;;IA0C7D,uEAAuE;IAGjE,iBAAiB,CACd,GAAG,EAAE,aAAa,EACN,QAAQ,EAAE,MAAM,EAC3B,IAAI,EAAE;QAAE,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE;;;IAUxC;;;;OAIG;IAGG,gBAAgB,CACb,GAAG,EAAE,aAAa,EACA,cAAc,EAAE,MAAM,EACvC,IAAI,EAAE;QAAE,QAAQ,CAAC,EAAE,OAAO,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE;;;IAgB1E,2FAA2F;IAGrF,gBAAgB,CACb,GAAG,EAAE,aAAa,EACA,cAAc,EAAE,MAAM;;;IAMjD,0EAA0E;IAGpE,eAAe,CACZ,GAAG,EAAE,aAAa,EACA,cAAc,EAAE,MAAM,EACvC,IAAI,EAAE;QAAE,QAAQ,CAAC,EAAE,YAAY,EAAE,CAAA;KAAE;;;IAO7C;;;;OAIG;YACW,WAAW;CAM1B"}
|
|
@@ -19,12 +19,16 @@ const membership_service_js_1 = require("./membership.service.js");
|
|
|
19
19
|
const posting_service_js_1 = require("./posting.service.js");
|
|
20
20
|
const record_resolver_js_1 = require("../shared/record-resolver.js");
|
|
21
21
|
const http_js_1 = require("../shared/http.js");
|
|
22
|
+
const identity_error_interceptor_js_1 = require("../shared/identity-error.interceptor.js");
|
|
22
23
|
// ── Memberships + postings ───────────────────────────────────────────────────
|
|
23
24
|
//
|
|
24
25
|
// Gated on the `company_membership` / `membership_posting` grants via `@Classified`
|
|
25
26
|
// + the engine's mandatory in-pipe gate. Both are DATA-plane types, so the capped root
|
|
26
27
|
// is denied them by construction — the membership admin surface is grant-driven, not
|
|
27
|
-
// root-driven.
|
|
28
|
+
// root-driven. Per-company-admin scoping is structural: reads, updates and deletes are walled
|
|
29
|
+
// to the caller's subtree (ADR-0027/0030), creates by the ADR-0034 create wall; adding an
|
|
30
|
+
// existing user needs that user already in the subtree (D4), and posting a role runs the
|
|
31
|
+
// role-assignment ceiling + binding rule (#11). Errors map via IdentityErrorInterceptor.
|
|
28
32
|
let MembershipController = class MembershipController {
|
|
29
33
|
memberships;
|
|
30
34
|
postings;
|
|
@@ -78,8 +82,8 @@ let MembershipController = class MembershipController {
|
|
|
78
82
|
return { memberships: cards };
|
|
79
83
|
}
|
|
80
84
|
/** Add the user to a company (creates an inactive membership card). */
|
|
81
|
-
async addUserMembership(userUuid, body) {
|
|
82
|
-
const id = await this.memberships.addMembership(userUuid, body?.companyUuid ?? '');
|
|
85
|
+
async addUserMembership(req, userUuid, body) {
|
|
86
|
+
const id = await this.memberships.addMembership(req.principal, userUuid, body?.companyUuid ?? '');
|
|
83
87
|
return { id };
|
|
84
88
|
}
|
|
85
89
|
/**
|
|
@@ -87,26 +91,29 @@ let MembershipController = class MembershipController {
|
|
|
87
91
|
* invariant); `status` is a plain field update. Activating requires the owning user so
|
|
88
92
|
* the invariant can deactivate the user's OTHER memberships.
|
|
89
93
|
*/
|
|
90
|
-
async updateMembership(membershipUuid, body) {
|
|
94
|
+
async updateMembership(req, membershipUuid, body) {
|
|
91
95
|
if (body?.isActive === true) {
|
|
92
96
|
if (!body.userUuid) {
|
|
93
97
|
throw new common_1.ForbiddenException('userUuid is required to activate a membership');
|
|
94
98
|
}
|
|
95
|
-
await this.memberships.setActive(membershipUuid, body.userUuid);
|
|
99
|
+
await this.memberships.setActive(req.principal, membershipUuid, body.userUuid);
|
|
96
100
|
}
|
|
97
101
|
if (typeof body?.status === 'string') {
|
|
98
|
-
await this.memberships.updateMembership(membershipUuid, {
|
|
102
|
+
await this.memberships.updateMembership(req.principal, membershipUuid, {
|
|
103
|
+
status: body.status,
|
|
104
|
+
});
|
|
99
105
|
}
|
|
100
106
|
return { success: true };
|
|
101
107
|
}
|
|
102
|
-
/** Remove a membership card (its postings cascade at the DB). */
|
|
103
|
-
async removeMembership(membershipUuid) {
|
|
104
|
-
await this.memberships.removeMembership(membershipUuid);
|
|
108
|
+
/** Remove a membership card (its postings cascade at the DB). A peer's needs dominance. */
|
|
109
|
+
async removeMembership(req, membershipUuid) {
|
|
110
|
+
await this.memberships.removeMembership(req.principal, membershipUuid);
|
|
105
111
|
return { success: true };
|
|
106
112
|
}
|
|
107
113
|
/** Atomically replace a membership's full posting set (the diff sync). */
|
|
108
|
-
async replacePostings(membershipUuid, body) {
|
|
109
|
-
|
|
114
|
+
async replacePostings(req, membershipUuid, body) {
|
|
115
|
+
const postings = Array.isArray(body?.postings) ? body.postings : [];
|
|
116
|
+
await this.postings.replacePostings(req.principal, membershipUuid, postings);
|
|
110
117
|
return { success: true };
|
|
111
118
|
}
|
|
112
119
|
/**
|
|
@@ -134,40 +141,45 @@ __decorate([
|
|
|
134
141
|
__decorate([
|
|
135
142
|
(0, common_1.Post)('users/:userUuid/memberships'),
|
|
136
143
|
(0, guard_1.Classified)({ typeKey: 'company_membership', action: 'create' }),
|
|
137
|
-
__param(0, (0, common_1.
|
|
138
|
-
__param(1, (0, common_1.
|
|
144
|
+
__param(0, (0, common_1.Req)()),
|
|
145
|
+
__param(1, (0, common_1.Param)('userUuid')),
|
|
146
|
+
__param(2, (0, common_1.Body)()),
|
|
139
147
|
__metadata("design:type", Function),
|
|
140
|
-
__metadata("design:paramtypes", [String, Object]),
|
|
148
|
+
__metadata("design:paramtypes", [Object, String, Object]),
|
|
141
149
|
__metadata("design:returntype", Promise)
|
|
142
150
|
], MembershipController.prototype, "addUserMembership", null);
|
|
143
151
|
__decorate([
|
|
144
152
|
(0, common_1.Put)('memberships/:membershipUuid'),
|
|
145
153
|
(0, guard_1.Classified)({ typeKey: 'company_membership', action: 'update' }),
|
|
146
|
-
__param(0, (0, common_1.
|
|
147
|
-
__param(1, (0, common_1.
|
|
154
|
+
__param(0, (0, common_1.Req)()),
|
|
155
|
+
__param(1, (0, common_1.Param)('membershipUuid')),
|
|
156
|
+
__param(2, (0, common_1.Body)()),
|
|
148
157
|
__metadata("design:type", Function),
|
|
149
|
-
__metadata("design:paramtypes", [String, Object]),
|
|
158
|
+
__metadata("design:paramtypes", [Object, String, Object]),
|
|
150
159
|
__metadata("design:returntype", Promise)
|
|
151
160
|
], MembershipController.prototype, "updateMembership", null);
|
|
152
161
|
__decorate([
|
|
153
162
|
(0, common_1.Delete)('memberships/:membershipUuid'),
|
|
154
163
|
(0, guard_1.Classified)({ typeKey: 'company_membership', action: 'delete' }),
|
|
155
|
-
__param(0, (0, common_1.
|
|
164
|
+
__param(0, (0, common_1.Req)()),
|
|
165
|
+
__param(1, (0, common_1.Param)('membershipUuid')),
|
|
156
166
|
__metadata("design:type", Function),
|
|
157
|
-
__metadata("design:paramtypes", [String]),
|
|
167
|
+
__metadata("design:paramtypes", [Object, String]),
|
|
158
168
|
__metadata("design:returntype", Promise)
|
|
159
169
|
], MembershipController.prototype, "removeMembership", null);
|
|
160
170
|
__decorate([
|
|
161
171
|
(0, common_1.Put)('memberships/:membershipUuid/postings'),
|
|
162
172
|
(0, guard_1.Classified)({ typeKey: 'membership_posting', action: 'update' }),
|
|
163
|
-
__param(0, (0, common_1.
|
|
164
|
-
__param(1, (0, common_1.
|
|
173
|
+
__param(0, (0, common_1.Req)()),
|
|
174
|
+
__param(1, (0, common_1.Param)('membershipUuid')),
|
|
175
|
+
__param(2, (0, common_1.Body)()),
|
|
165
176
|
__metadata("design:type", Function),
|
|
166
|
-
__metadata("design:paramtypes", [String, Object]),
|
|
177
|
+
__metadata("design:paramtypes", [Object, String, Object]),
|
|
167
178
|
__metadata("design:returntype", Promise)
|
|
168
179
|
], MembershipController.prototype, "replacePostings", null);
|
|
169
180
|
exports.MembershipController = MembershipController = __decorate([
|
|
170
181
|
(0, common_1.Controller)('identity'),
|
|
182
|
+
(0, common_1.UseInterceptors)(identity_error_interceptor_js_1.IdentityErrorInterceptor),
|
|
171
183
|
__metadata("design:paramtypes", [membership_service_js_1.MembershipService,
|
|
172
184
|
posting_service_js_1.PostingService,
|
|
173
185
|
record_resolver_js_1.RecordResolverService])
|
|
@@ -11,17 +11,20 @@
|
|
|
11
11
|
* `cm_is_active`; `setActive` is the guard.
|
|
12
12
|
*/
|
|
13
13
|
import { RecordsService, type RecordId, type RecordView } from '@astram/engine';
|
|
14
|
+
import type { Principal } from '@astram/core';
|
|
14
15
|
import { type PostingVersionBump } from './posting-version-bump.js';
|
|
16
|
+
import { GrantCeiling } from '../authz/grant-ceiling.js';
|
|
15
17
|
export declare class MembershipService {
|
|
16
18
|
private readonly records;
|
|
19
|
+
private readonly ceiling;
|
|
17
20
|
private readonly versionBump?;
|
|
18
|
-
constructor(records: RecordsService, versionBump?: (PostingVersionBump | null) | undefined);
|
|
21
|
+
constructor(records: RecordsService, ceiling: GrantCeiling, versionBump?: (PostingVersionBump | null) | undefined);
|
|
19
22
|
/**
|
|
20
|
-
* Read a membership
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
+
* Read a membership through the subtree wall (404 when out of reach) and, unless the actor is
|
|
24
|
+
* acting on their OWN membership, require the actor to dominate its user (security review): a
|
|
25
|
+
* sub-admin must not delete, suspend or re-activate a more privileged peer's membership.
|
|
23
26
|
*/
|
|
24
|
-
private
|
|
27
|
+
private reachMember;
|
|
25
28
|
/**
|
|
26
29
|
* Bump the permission version for each DISTINCT tenant in `tenantUuids` (best-effort +
|
|
27
30
|
* fail-safe): the mutation already succeeded, so a bump hiccup must NOT surface as an
|
|
@@ -37,17 +40,17 @@ export declare class MembershipService {
|
|
|
37
40
|
* hits the DB's UNIQUE constraint and the engine's error surfaces to the caller (NOT
|
|
38
41
|
* swallowed here). `createdBy` is stamped server-side (actorId-trusted).
|
|
39
42
|
*/
|
|
40
|
-
addMembership(userUuid: string, companyUuid: string): Promise<RecordId>;
|
|
43
|
+
addMembership(actor: Principal, userUuid: string, companyUuid: string): Promise<RecordId>;
|
|
41
44
|
/** List a user's membership cards (tenant-scoped by the pipe). */
|
|
42
45
|
listMemberships(userUuid: string): Promise<readonly RecordView[]>;
|
|
43
46
|
/**
|
|
44
47
|
* Flip which of the user's memberships is active — the ONE-ACTIVE invariant. The service
|
|
45
48
|
* is the guard because the DB has no partial-unique on `cm_is_active`.
|
|
46
49
|
*
|
|
47
|
-
* ⚠️ ORDER IS LOAD-BEARING (fail-safe against a LOGIN LOCKOUT).
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* deactivate the others:
|
|
50
|
+
* ⚠️ ORDER IS LOAD-BEARING (fail-safe against a LOGIN LOCKOUT). Each `update` is its OWN
|
|
51
|
+
* transaction (no cross-call atomicity), and the engine's `list` order (PK-ordered since the
|
|
52
|
+
* Phase 3 tiebreaker) says nothing about which membership is the target. So this MUST
|
|
53
|
+
* activate the TARGET FIRST, then deactivate the others:
|
|
51
54
|
* - Worst case of a mid-loop failure is then a transient TWO-active window, which is
|
|
52
55
|
* RECOVERABLE — the credential resolver reads `is_active=1 LIMIT 1`, so it still
|
|
53
56
|
* resolves a tenant and login works.
|
|
@@ -58,16 +61,19 @@ export declare class MembershipService {
|
|
|
58
61
|
* Guard: if `membershipUuid` is NOT one of the user's memberships, throw — otherwise the
|
|
59
62
|
* loop would deactivate everything and activate nothing (also a zero-active lockout).
|
|
60
63
|
*/
|
|
61
|
-
setActive(membershipUuid: string, userUuid: string): Promise<void>;
|
|
64
|
+
setActive(actor: Principal, membershipUuid: string, userUuid: string): Promise<void>;
|
|
62
65
|
/**
|
|
63
66
|
* Update a membership's mutable non-activity fields (currently `status`). Activity is
|
|
64
67
|
* flipped only via `setActive` to preserve the one-active invariant, so `isActive` is
|
|
65
68
|
* intentionally NOT a patch key here.
|
|
66
69
|
*/
|
|
67
|
-
updateMembership(membershipUuid: string, patch: {
|
|
70
|
+
updateMembership(actor: Principal, membershipUuid: string, patch: {
|
|
68
71
|
status?: string;
|
|
69
72
|
}): Promise<void>;
|
|
70
|
-
/**
|
|
71
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Remove a membership card (its postings cascade at the DB via FK ON DELETE CASCADE). A peer's
|
|
75
|
+
* membership needs dominance over the peer (security review); your own does not.
|
|
76
|
+
*/
|
|
77
|
+
removeMembership(actor: Principal, membershipUuid: string): Promise<void>;
|
|
72
78
|
}
|
|
73
79
|
//# sourceMappingURL=membership.service.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"membership.service.d.ts","sourceRoot":"","sources":["../../../src/features/membership/membership.service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,EAAE,cAAc,EAAE,KAAK,QAAQ,EAAE,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAChF,OAAO,EAAwB,KAAK,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAE1F,qBACa,iBAAiB;IAE1B,OAAO,CAAC,QAAQ,CAAC,OAAO;IAQxB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"membership.service.d.ts","sourceRoot":"","sources":["../../../src/features/membership/membership.service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,EAAE,cAAc,EAAE,KAAK,QAAQ,EAAE,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAChF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAwB,KAAK,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAE1F,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAEzD,qBACa,iBAAiB;IAE1B,OAAO,CAAC,QAAQ,CAAC,OAAO;IAGxB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAQxB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;gBAXZ,OAAO,EAAE,cAAc,EAGvB,OAAO,EAAE,YAAY,EAQrB,WAAW,CAAC,GAAE,kBAAkB,GAAG,IAAI,aAAA;IAG1D;;;;OAIG;YACW,WAAW;IAQzB;;;;;;OAMG;YACW,WAAW;IAYzB;;;;;;OAMG;IACG,aAAa,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC;IAkB/F,kEAAkE;IAC5D,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,UAAU,EAAE,CAAC;IAKvE;;;;;;;;;;;;;;;;;OAiBG;IACG,SAAS,CAAC,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAsC1F;;;;OAIG;IACG,gBAAgB,CACpB,KAAK,EAAE,SAAS,EAChB,cAAc,EAAE,MAAM,EACtB,KAAK,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,GACzB,OAAO,CAAC,IAAI,CAAC;IAchB;;;OAGG;IACG,gBAAgB,CAAC,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAYhF"}
|
|
@@ -28,27 +28,33 @@ exports.MembershipService = void 0;
|
|
|
28
28
|
const common_1 = require("@nestjs/common");
|
|
29
29
|
const engine_1 = require("@astram/engine");
|
|
30
30
|
const posting_version_bump_js_1 = require("./posting-version-bump.js");
|
|
31
|
+
const identity_errors_js_1 = require("../shared/identity-errors.js");
|
|
32
|
+
const grant_ceiling_js_1 = require("../authz/grant-ceiling.js");
|
|
31
33
|
let MembershipService = class MembershipService {
|
|
32
34
|
records;
|
|
35
|
+
ceiling;
|
|
33
36
|
versionBump;
|
|
34
|
-
constructor(records,
|
|
37
|
+
constructor(records,
|
|
38
|
+
// Dominance over the member for destructive ops (security review): REQUIRED, so a missing
|
|
39
|
+
// ceiling fails DI at boot instead of silently skipping the check.
|
|
40
|
+
ceiling, versionBump) {
|
|
35
41
|
this.records = records;
|
|
42
|
+
this.ceiling = ceiling;
|
|
36
43
|
this.versionBump = versionBump;
|
|
37
44
|
}
|
|
38
45
|
/**
|
|
39
|
-
* Read a membership
|
|
40
|
-
*
|
|
41
|
-
*
|
|
46
|
+
* Read a membership through the subtree wall (404 when out of reach) and, unless the actor is
|
|
47
|
+
* acting on their OWN membership, require the actor to dominate its user (security review): a
|
|
48
|
+
* sub-admin must not delete, suspend or re-activate a more privileged peer's membership.
|
|
42
49
|
*/
|
|
43
|
-
async
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
50
|
+
async reachMember(actor, membershipUuid) {
|
|
51
|
+
const m = await this.records.get('company_membership', membershipUuid);
|
|
52
|
+
if (m === null)
|
|
53
|
+
throw new identity_errors_js_1.IdentityNotFoundError('Membership not found.');
|
|
54
|
+
const userUuid = typeof m.fields?.userUuid === 'string' ? m.fields.userUuid : '';
|
|
55
|
+
if (userUuid !== actor.id)
|
|
56
|
+
await this.ceiling.assertDominates(actor, userUuid);
|
|
57
|
+
return m;
|
|
52
58
|
}
|
|
53
59
|
/**
|
|
54
60
|
* Bump the permission version for each DISTINCT tenant in `tenantUuids` (best-effort +
|
|
@@ -77,7 +83,20 @@ let MembershipService = class MembershipService {
|
|
|
77
83
|
* hits the DB's UNIQUE constraint and the engine's error surfaces to the caller (NOT
|
|
78
84
|
* swallowed here). `createdBy` is stamped server-side (actorId-trusted).
|
|
79
85
|
*/
|
|
80
|
-
addMembership(userUuid, companyUuid) {
|
|
86
|
+
async addMembership(actor, userUuid, companyUuid) {
|
|
87
|
+
// D4 (security hardening #4): a non-root actor may attach only a user ALREADY inside its
|
|
88
|
+
// subtree — the user's walled read (a user is in scope iff it has a membership in the
|
|
89
|
+
// actor's subtree) must see it. Pulling a foreign tenant's user in would make them
|
|
90
|
+
// "dominated" by the actor (the #4 + #3 email-takeover chain). A brand-new user joins
|
|
91
|
+
// through `POST /identity/users { companyUuid }` (created + attached in one transaction).
|
|
92
|
+
// Root is data-plane-denied on memberships by the engine anyway.
|
|
93
|
+
if (actor.isRoot !== true) {
|
|
94
|
+
const user = await this.records.get('user', userUuid);
|
|
95
|
+
if (user === null)
|
|
96
|
+
throw new identity_errors_js_1.IdentityNotFoundError('User not found.');
|
|
97
|
+
}
|
|
98
|
+
// The company itself is checked by the engine's ADR-0034 create wall: a company outside
|
|
99
|
+
// the actor's subtree ⇒ AuthorizationDeniedError (→ 403) and the INSERT rolls back.
|
|
81
100
|
return this.records.create('company_membership', {
|
|
82
101
|
fields: { userUuid, companyUuid, isActive: false, status: 'active' },
|
|
83
102
|
});
|
|
@@ -91,10 +110,10 @@ let MembershipService = class MembershipService {
|
|
|
91
110
|
* Flip which of the user's memberships is active — the ONE-ACTIVE invariant. The service
|
|
92
111
|
* is the guard because the DB has no partial-unique on `cm_is_active`.
|
|
93
112
|
*
|
|
94
|
-
* ⚠️ ORDER IS LOAD-BEARING (fail-safe against a LOGIN LOCKOUT).
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* deactivate the others:
|
|
113
|
+
* ⚠️ ORDER IS LOAD-BEARING (fail-safe against a LOGIN LOCKOUT). Each `update` is its OWN
|
|
114
|
+
* transaction (no cross-call atomicity), and the engine's `list` order (PK-ordered since the
|
|
115
|
+
* Phase 3 tiebreaker) says nothing about which membership is the target. So this MUST
|
|
116
|
+
* activate the TARGET FIRST, then deactivate the others:
|
|
98
117
|
* - Worst case of a mid-loop failure is then a transient TWO-active window, which is
|
|
99
118
|
* RECOVERABLE — the credential resolver reads `is_active=1 LIMIT 1`, so it still
|
|
100
119
|
* resolves a tenant and login works.
|
|
@@ -105,13 +124,17 @@ let MembershipService = class MembershipService {
|
|
|
105
124
|
* Guard: if `membershipUuid` is NOT one of the user's memberships, throw — otherwise the
|
|
106
125
|
* loop would deactivate everything and activate nothing (also a zero-active lockout).
|
|
107
126
|
*/
|
|
108
|
-
async setActive(membershipUuid, userUuid) {
|
|
127
|
+
async setActive(actor, membershipUuid, userUuid) {
|
|
128
|
+
// Flipping activation de-activates the user's OTHER memberships: for a peer, the actor must
|
|
129
|
+
// dominate them (security review). Your own activation stays allowed.
|
|
130
|
+
if (userUuid !== actor.id)
|
|
131
|
+
await this.ceiling.assertDominates(actor, userUuid);
|
|
109
132
|
const page = await this.records.list('company_membership', { where: { userUuid } });
|
|
110
133
|
const target = page.rows.find((m) => m.id === membershipUuid);
|
|
111
134
|
if (!target) {
|
|
112
135
|
// Not this user's membership → refuse rather than leave the user with ZERO active
|
|
113
|
-
// (a login lockout). Fail-closed.
|
|
114
|
-
throw new
|
|
136
|
+
// (a login lockout). Fail-closed. No ids in the message (ADR-0012).
|
|
137
|
+
throw new identity_errors_js_1.IdentityNotFoundError('Membership not found.');
|
|
115
138
|
}
|
|
116
139
|
// 1) TARGET FIRST — guarantees an active membership exists before any deactivation, so
|
|
117
140
|
// a mid-failure below leaves TWO active (recoverable), never ZERO (lockout).
|
|
@@ -142,7 +165,11 @@ let MembershipService = class MembershipService {
|
|
|
142
165
|
* flipped only via `setActive` to preserve the one-active invariant, so `isActive` is
|
|
143
166
|
* intentionally NOT a patch key here.
|
|
144
167
|
*/
|
|
145
|
-
updateMembership(membershipUuid, patch) {
|
|
168
|
+
async updateMembership(actor, membershipUuid, patch) {
|
|
169
|
+
await this.reachMember(actor, membershipUuid); // 404 / dominance over a peer
|
|
170
|
+
const fields = {};
|
|
171
|
+
if (typeof patch?.status === 'string')
|
|
172
|
+
fields.status = patch.status;
|
|
146
173
|
// NO version bump here (ADR-0011): this patches `status` ONLY — `isActive` is
|
|
147
174
|
// deliberately not a patch key (activity flips solely via `setActive`, which bumps),
|
|
148
175
|
// and the controller only ever passes `{ status }`. Coarse-grant resolution gates on
|
|
@@ -150,16 +177,21 @@ let MembershipService = class MembershipService {
|
|
|
150
177
|
// resolve → the warm cache stays correct → no invalidation needed. If a future patch key
|
|
151
178
|
// is ever added here that CAN change which roles/postings resolve, it MUST bump the
|
|
152
179
|
// membership's tenant (mirror `setActive`).
|
|
153
|
-
|
|
180
|
+
await this.records.update('company_membership', membershipUuid, { fields });
|
|
154
181
|
}
|
|
155
|
-
/**
|
|
156
|
-
|
|
182
|
+
/**
|
|
183
|
+
* Remove a membership card (its postings cascade at the DB via FK ON DELETE CASCADE). A peer's
|
|
184
|
+
* membership needs dominance over the peer (security review); your own does not.
|
|
185
|
+
*/
|
|
186
|
+
async removeMembership(actor, membershipUuid) {
|
|
157
187
|
// ADR-0011 STALENESS FIX: resolve the membership's tenant BEFORE the delete (it's
|
|
158
188
|
// unreadable afterwards). If the membership was active, deleting it (postings cascade)
|
|
159
189
|
// removes the user's role grants in that company — a coarse-grant change — so bump that
|
|
160
190
|
// tenant AFTER the delete succeeds. Best-effort/fail-safe (bumpTenants swallows). A
|
|
161
191
|
// membership belongs to exactly one company → one tenant bump.
|
|
162
|
-
const
|
|
192
|
+
const m = await this.reachMember(actor, membershipUuid);
|
|
193
|
+
const c = m.fields?.companyUuid;
|
|
194
|
+
const tenant = typeof c === 'string' && c.length > 0 ? c : null;
|
|
163
195
|
await this.records.delete('company_membership', membershipUuid);
|
|
164
196
|
await this.bumpTenants([tenant]);
|
|
165
197
|
}
|
|
@@ -167,7 +199,8 @@ let MembershipService = class MembershipService {
|
|
|
167
199
|
exports.MembershipService = MembershipService;
|
|
168
200
|
exports.MembershipService = MembershipService = __decorate([
|
|
169
201
|
(0, common_1.Injectable)(),
|
|
170
|
-
__param(
|
|
171
|
-
__param(
|
|
172
|
-
__metadata("design:paramtypes", [engine_1.RecordsService,
|
|
202
|
+
__param(2, (0, common_1.Optional)()),
|
|
203
|
+
__param(2, (0, common_1.Inject)(posting_version_bump_js_1.POSTING_VERSION_BUMP)),
|
|
204
|
+
__metadata("design:paramtypes", [engine_1.RecordsService,
|
|
205
|
+
grant_ceiling_js_1.GrantCeiling, Object])
|
|
173
206
|
], MembershipService);
|