@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,26 @@
|
|
|
1
|
+
import { type DynamicModule } from '@nestjs/common';
|
|
2
|
+
/**
|
|
3
|
+
* IdentityFeatureModule — the framework-owned identity FEATURE surface (company/user/role CRUD;
|
|
4
|
+
* more in later phases) as thin controllers over the engine's RecordsService. The CONSUMER imports
|
|
5
|
+
* `IdentityFeatureModule.forRoot()` alongside `ShieldModule.forRoot(...)` (ADR-0026) — @astram/guard
|
|
6
|
+
* never imports @astram/identity. `forRoot()` SELF-WIRES the engine (EngineModule.forRoot over the
|
|
7
|
+
* app's APP_DB_* env) + registers the identity objects + mounts the CRUD routes, so the consumer
|
|
8
|
+
* needs no separate engine setup.
|
|
9
|
+
*
|
|
10
|
+
* CONTROLLER ORDER: RoleController is kept ahead of any future grant-admin controller (Phase 4) with
|
|
11
|
+
* a conflicting `GET identity/roles/:uuid` path — a Nest route-precedence constraint.
|
|
12
|
+
*/
|
|
13
|
+
export declare class IdentityFeatureModule {
|
|
14
|
+
/**
|
|
15
|
+
* @param rootIds the framework's root allowlist — pass the SAME array given to
|
|
16
|
+
* `ShieldModule.forRoot({ rootIds })`. It seeds the cross-tenant grant writer's
|
|
17
|
+
* `isRoot` closure so a root caller who passes the route guard is not then denied
|
|
18
|
+
* by the writer's own `decide()` (a route-allows/writer-denies split). Omitted ⇒
|
|
19
|
+
* `isRoot` is always false (a non-root consumer works; only a root offering
|
|
20
|
+
* cross-tenant grants needs it). See ADR-0026 + the template wiring.
|
|
21
|
+
*/
|
|
22
|
+
static forRoot({ rootIds }?: {
|
|
23
|
+
rootIds?: readonly string[];
|
|
24
|
+
}): DynamicModule;
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=identity-feature.module.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"identity-feature.module.d.ts","sourceRoot":"","sources":["../../src/features/identity-feature.module.ts"],"names":[],"mappings":"AAAA,OAAO,EAAU,KAAK,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAoE5D;;;;;;;;;;GAUG;AACH,qBACa,qBAAqB;IAChC;;;;;;;OAOG;IACH,MAAM,CAAC,OAAO,CAAC,EAAE,OAAO,EAAE,GAAE;QAAE,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;KAAO,GAAG,aAAa;CAkLjF"}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
var IdentityFeatureModule_1;
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.IdentityFeatureModule = void 0;
|
|
11
|
+
const common_1 = require("@nestjs/common");
|
|
12
|
+
const core_1 = require("@nestjs/core");
|
|
13
|
+
const engine_1 = require("@astram/engine");
|
|
14
|
+
const guard_1 = require("@astram/guard");
|
|
15
|
+
const objects_js_1 = require("../objects.js");
|
|
16
|
+
const index_js_1 = require("./access/index.js");
|
|
17
|
+
const company_service_js_1 = require("./company/company.service.js");
|
|
18
|
+
const company_controller_js_1 = require("./company/company.controller.js");
|
|
19
|
+
const role_service_js_1 = require("./role/role.service.js");
|
|
20
|
+
const role_controller_js_1 = require("./role/role.controller.js");
|
|
21
|
+
const user_service_js_1 = require("./user/user.service.js");
|
|
22
|
+
const user_controller_js_1 = require("./user/user.controller.js");
|
|
23
|
+
const membership_controller_js_1 = require("./membership/membership.controller.js");
|
|
24
|
+
const membership_service_js_1 = require("./membership/membership.service.js");
|
|
25
|
+
const posting_service_js_1 = require("./membership/posting.service.js");
|
|
26
|
+
const org_catalog_controller_js_1 = require("./org-structure/org-catalog.controller.js");
|
|
27
|
+
const org_catalog_service_js_1 = require("./org-structure/org-catalog.service.js");
|
|
28
|
+
const record_resolver_js_1 = require("./shared/record-resolver.js");
|
|
29
|
+
const posting_version_bump_js_1 = require("./membership/posting-version-bump.js");
|
|
30
|
+
const permission_version_bumper_js_1 = require("./authz/permission-version.bumper.js");
|
|
31
|
+
const record_authorization_js_1 = require("./authz/record-authorization.js");
|
|
32
|
+
const db_permission_facts_adapter_js_1 = require("./authz/db-permission-facts.adapter.js");
|
|
33
|
+
const access_bundle_store_js_1 = require("./authz/access-bundle-store.js");
|
|
34
|
+
const grant_admin_service_js_1 = require("./authz/grant-admin.service.js");
|
|
35
|
+
const grant_admin_controller_js_1 = require("./authz/grant-admin.controller.js");
|
|
36
|
+
const catalog_reconcile_job_js_1 = require("./authz/catalog-reconcile.job.js");
|
|
37
|
+
const access_bundle_reconcile_job_js_1 = require("./authz/access-bundle-reconcile.job.js");
|
|
38
|
+
const catalog_bootstrap_provider_js_1 = require("./authz/catalog-bootstrap.provider.js");
|
|
39
|
+
const mariadb_audit_reader_js_1 = require("./audit/mariadb-audit-reader.js");
|
|
40
|
+
const directory_label_resolver_js_1 = require("./audit/directory-label-resolver.js");
|
|
41
|
+
const audit_read_service_js_1 = require("./audit/audit-read.service.js");
|
|
42
|
+
const audit_controller_js_1 = require("./audit/audit.controller.js");
|
|
43
|
+
const cross_tenant_grant_read_service_js_1 = require("./cross-tenant/cross-tenant-grant-read.service.js");
|
|
44
|
+
const cross_tenant_grant_controller_js_1 = require("./cross-tenant/cross-tenant-grant.controller.js");
|
|
45
|
+
const grant_authorize_js_1 = require("./cross-tenant/grant-authorize.js");
|
|
46
|
+
/** Map guard's env DbConfig → the engine's EngineDbConfig (adds the pool cap the engine needs). */
|
|
47
|
+
function engineDbFromEnv() {
|
|
48
|
+
const db = (0, guard_1.loadDbConfig)();
|
|
49
|
+
return {
|
|
50
|
+
host: db.host,
|
|
51
|
+
port: db.port,
|
|
52
|
+
user: db.user,
|
|
53
|
+
password: db.password,
|
|
54
|
+
database: db.database,
|
|
55
|
+
// The engine's runtime CRUD pool cap. A conservative default; the app can raise it later via
|
|
56
|
+
// an explicit EngineModule.forRoot if it needs more. (ADR-0018 budget lives app-side.)
|
|
57
|
+
connectionLimit: Number(process.env.APP_DB_MAX_CONNECTIONS ?? 10),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* IdentityFeatureModule — the framework-owned identity FEATURE surface (company/user/role CRUD;
|
|
62
|
+
* more in later phases) as thin controllers over the engine's RecordsService. The CONSUMER imports
|
|
63
|
+
* `IdentityFeatureModule.forRoot()` alongside `ShieldModule.forRoot(...)` (ADR-0026) — @astram/guard
|
|
64
|
+
* never imports @astram/identity. `forRoot()` SELF-WIRES the engine (EngineModule.forRoot over the
|
|
65
|
+
* app's APP_DB_* env) + registers the identity objects + mounts the CRUD routes, so the consumer
|
|
66
|
+
* needs no separate engine setup.
|
|
67
|
+
*
|
|
68
|
+
* CONTROLLER ORDER: RoleController is kept ahead of any future grant-admin controller (Phase 4) with
|
|
69
|
+
* a conflicting `GET identity/roles/:uuid` path — a Nest route-precedence constraint.
|
|
70
|
+
*/
|
|
71
|
+
let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModule {
|
|
72
|
+
/**
|
|
73
|
+
* @param rootIds the framework's root allowlist — pass the SAME array given to
|
|
74
|
+
* `ShieldModule.forRoot({ rootIds })`. It seeds the cross-tenant grant writer's
|
|
75
|
+
* `isRoot` closure so a root caller who passes the route guard is not then denied
|
|
76
|
+
* by the writer's own `decide()` (a route-allows/writer-denies split). Omitted ⇒
|
|
77
|
+
* `isRoot` is always false (a non-root consumer works; only a root offering
|
|
78
|
+
* cross-tenant grants needs it). See ADR-0026 + the template wiring.
|
|
79
|
+
*/
|
|
80
|
+
static forRoot({ rootIds } = {}) {
|
|
81
|
+
// ONE shared shield_authz pool for the whole grant-admin stack (ADR-0011): the
|
|
82
|
+
// permission-version bumper, the AccessBundleStore, the GrantAdminService, and the two
|
|
83
|
+
// reconcile jobs all run over this single pool (don't open several). Opened once at
|
|
84
|
+
// forRoot() (not import time) — mirrors the pre-existing bump-pool pattern.
|
|
85
|
+
const authzPool = (0, record_authorization_js_1.createIdentityAuthzPool)();
|
|
86
|
+
const bumper = new permission_version_bumper_js_1.PermissionVersionBumper();
|
|
87
|
+
const postingVersionBump = {
|
|
88
|
+
bumpTenant: (tenantUuid) => bumper.bumpForTenant(tenantUuid, authzPool),
|
|
89
|
+
};
|
|
90
|
+
// The grant-admin collaborators built at forRoot() over the shared pool (mirrors the
|
|
91
|
+
// postingVersionBump closure pattern above).
|
|
92
|
+
const accessBundleStore = new access_bundle_store_js_1.AccessBundleStore(authzPool);
|
|
93
|
+
const grantAdminService = new grant_admin_service_js_1.GrantAdminService(authzPool, bumper, accessBundleStore);
|
|
94
|
+
// The core ModuleRegistry the catalog reconcile job needs, built from the identity
|
|
95
|
+
// object registrations (guard's bridge reflects decorated classes → registry).
|
|
96
|
+
const identityRegistry = (0, guard_1.buildIdentityModuleRegistry)([...objects_js_1.identityObjects]);
|
|
97
|
+
// Audit viewer (Phase 5): the AuditReader + LabelResolver over the SAME shared authzPool
|
|
98
|
+
// (the reader queries shield_audit + the resolver/actor-lookup query shield_app, both
|
|
99
|
+
// reachable by that pool). `auditNameToId` is the actor-name -> uuid reverse lookup for the
|
|
100
|
+
// actor filter (astram3's exact closure). A mariadb Pool.query(sql, params) structurally
|
|
101
|
+
// satisfies the reader/resolver's `AuditQueryable` — pass authzPool directly. Built at
|
|
102
|
+
// forRoot() (not via Nest DI) because it needs the hand-built pool, then provided useValue.
|
|
103
|
+
const auditReader = new mariadb_audit_reader_js_1.MariaDbAuditReader(authzPool);
|
|
104
|
+
const auditLabels = new directory_label_resolver_js_1.DirectoryLabelResolver(authzPool);
|
|
105
|
+
const auditNameToId = async (name) => {
|
|
106
|
+
const rows = (await authzPool.query('SELECT u_uuid AS id FROM `shield_app`.`users` WHERE u_username = ? LIMIT 1', [name]));
|
|
107
|
+
return rows[0]?.id ?? null;
|
|
108
|
+
};
|
|
109
|
+
const auditReadService = new audit_read_service_js_1.AuditReadService(auditReader, auditLabels, auditNameToId);
|
|
110
|
+
// Cross-tenant grants (Phase 6): the ENTIRE offer/accept/revoke surface, SELF-WIRED here
|
|
111
|
+
// over the SAME shared authzPool (no consumer code — astram3 hand-built this in its consumer
|
|
112
|
+
// authz.module, the anti-pattern this replaces). A SECOND DecisionAuthority is built over the
|
|
113
|
+
// same record-authorization inputs (matches astram3): the route guard's own decide() is the
|
|
114
|
+
// first; this one backs the writer's server-side re-verification.
|
|
115
|
+
const crossTenantAuthority = (0, guard_1.buildRecordAuthorization)((0, record_authorization_js_1.buildIdentityRecordAuthorization)(authzPool));
|
|
116
|
+
// The writer's `GrantAuthorizeFn`: decide()-backed, fail-closed (no try/catch — a throw
|
|
117
|
+
// propagates and the writer maps it to a retryable 503). isRoot ← the SAME rootIds the
|
|
118
|
+
// consumer passes to ShieldModule.forRoot (root-correctness for control-plane grants).
|
|
119
|
+
const authorizeCrossTenantGrant = (0, grant_authorize_js_1.makeAuthorizeCrossTenantGrant)({
|
|
120
|
+
authority: crossTenantAuthority,
|
|
121
|
+
readRoleIds: (principalId, tenantId) => (0, db_permission_facts_adapter_js_1.readRoleIdsForTenant)(authzPool, principalId, tenantId),
|
|
122
|
+
isRoot: (principalId) => (rootIds ?? []).includes(principalId),
|
|
123
|
+
});
|
|
124
|
+
// Audit bridge: map guard's `GrantAuditEvent` → the engine's `MariaDbAuditSink` (the SAME
|
|
125
|
+
// shield_audit.audit_log sink Phase 5's reader reads). The engine sink's `record(event, query)`
|
|
126
|
+
// takes a SECOND query fn (its rows commit on the pipe's txn); guard's `GrantAuditSink.record`
|
|
127
|
+
// takes ONE arg — so we adapt, running the write on the shared pool. The engine AuditEvent's
|
|
128
|
+
// `action` is an `Action` object (`{ key }`), while guard's is a 'grant.*' string — wrap it.
|
|
129
|
+
// AUDIT-ON-BOTH (ADR-0009 / invariant #6): a cross-tenant grant lifecycle event is filed under
|
|
130
|
+
// BOTH the owning tenant's AND the grantee tenant's audit trail — one row each — so a
|
|
131
|
+
// tenant-scoped auditor on EITHER side sees the event (a grantee-tenant auditor must see a
|
|
132
|
+
// grant accepted INTO their tenant, not just the owner's trail). Same tenant id twice (should
|
|
133
|
+
// never happen for a cross-tenant grant) is de-duped to ONE row.
|
|
134
|
+
// BEST-EFFORT: a throw here must NEVER fail the lifecycle write (the writer already swallows a
|
|
135
|
+
// sink throw; we also guard here so a mapping/DB error is inert).
|
|
136
|
+
const grantAuditSink = new engine_1.MariaDbAuditSink({}); // defaults: shield_audit.audit_log
|
|
137
|
+
const grantAudit = {
|
|
138
|
+
record: async (event) => {
|
|
139
|
+
try {
|
|
140
|
+
const tenantIds = event.granteeTenantId && event.granteeTenantId !== event.owningTenantId
|
|
141
|
+
? [event.owningTenantId, event.granteeTenantId]
|
|
142
|
+
: [event.owningTenantId];
|
|
143
|
+
for (const tenantId of tenantIds) {
|
|
144
|
+
await grantAuditSink.record({
|
|
145
|
+
type: 'authentication',
|
|
146
|
+
occurredAtMs: event.occurredAtMs,
|
|
147
|
+
actorId: event.actorId,
|
|
148
|
+
tenantId,
|
|
149
|
+
action: { key: event.action },
|
|
150
|
+
attributes: {
|
|
151
|
+
...event.attributes,
|
|
152
|
+
owningTenantId: event.owningTenantId,
|
|
153
|
+
granteeTenantId: event.granteeTenantId,
|
|
154
|
+
},
|
|
155
|
+
}, (sql, params) => authzPool.query(sql, params));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// Best-effort: an audit failure must not fail a legitimate lifecycle operation.
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
const crossTenantGrantWriter = new guard_1.CrossTenantGrantWriter({
|
|
164
|
+
db: authzPool,
|
|
165
|
+
authorize: authorizeCrossTenantGrant,
|
|
166
|
+
audit: grantAudit,
|
|
167
|
+
});
|
|
168
|
+
// The read side (inbox/mine/incoming/offerable-types) over the same pool + the merged object
|
|
169
|
+
// set (offerable = tenantScoped types only) + the per-type action vocabulary.
|
|
170
|
+
const crossTenantGrantReadService = new cross_tenant_grant_read_service_js_1.CrossTenantGrantReadService(authzPool, [...objects_js_1.identityObjects], guard_1.actionsFor);
|
|
171
|
+
return {
|
|
172
|
+
module: IdentityFeatureModule_1,
|
|
173
|
+
imports: [
|
|
174
|
+
// Collect the 3 identity access bundles into ENGINE_ALL_ACCESS_BUNDLES: forRoot does
|
|
175
|
+
// NOT take `accessBundles` — only forFeature accumulates them (engine.module.ts). MUST
|
|
176
|
+
// run BEFORE EngineModule.forRoot() below: `imports` array elements evaluate
|
|
177
|
+
// left-to-right, and forRoot's body snapshots the accumulator SYNCHRONOUSLY at call
|
|
178
|
+
// time (not in a DI factory) — so forFeature has to push into it first, or the
|
|
179
|
+
// snapshot is empty and no bundles reconcile into shield_authz.access_bundle.
|
|
180
|
+
engine_1.EngineModule.forFeature([], { accessBundles: index_js_1.identityAccessBundles }),
|
|
181
|
+
// The engine's runtime CRUD pipe + the identity object registrations.
|
|
182
|
+
engine_1.EngineModule.forRoot({ db: engineDbFromEnv(), registrations: [...objects_js_1.identityObjects] }),
|
|
183
|
+
// NestJS DiscoveryService/MetadataScanner — the boot bootstrap provider reflects the
|
|
184
|
+
// live controllers through this to build the OperationInventory.
|
|
185
|
+
core_1.DiscoveryModule,
|
|
186
|
+
],
|
|
187
|
+
// Route order: RoleController stays AHEAD of GrantAdminController (documented Nest
|
|
188
|
+
// route-precedence constraint — the summary route `access/role-grants` dodges
|
|
189
|
+
// `roles/:uuid`, but Role is registered first regardless).
|
|
190
|
+
controllers: [
|
|
191
|
+
company_controller_js_1.CompanyController,
|
|
192
|
+
role_controller_js_1.RoleController,
|
|
193
|
+
grant_admin_controller_js_1.GrantAdminController,
|
|
194
|
+
user_controller_js_1.UserController,
|
|
195
|
+
membership_controller_js_1.MembershipController,
|
|
196
|
+
org_catalog_controller_js_1.OrgCatalogController,
|
|
197
|
+
// `identity/audit` collides with no other route — order-independent; appended last.
|
|
198
|
+
audit_controller_js_1.AuditController,
|
|
199
|
+
// Cross-tenant grants — its `cross-tenant-grants/*` paths collide with nothing; the
|
|
200
|
+
// controller's own method order keeps the static sub-paths (/inbox, /offerable-types)
|
|
201
|
+
// ahead of `/:uuid/*`. Appended after the existing controllers.
|
|
202
|
+
cross_tenant_grant_controller_js_1.CrossTenantGrantController,
|
|
203
|
+
],
|
|
204
|
+
providers: [
|
|
205
|
+
company_service_js_1.CompanyService,
|
|
206
|
+
role_service_js_1.RoleService,
|
|
207
|
+
user_service_js_1.UserService,
|
|
208
|
+
membership_service_js_1.MembershipService,
|
|
209
|
+
posting_service_js_1.PostingService,
|
|
210
|
+
org_catalog_service_js_1.OrgCatalogService,
|
|
211
|
+
record_resolver_js_1.RecordResolverService,
|
|
212
|
+
{ provide: posting_version_bump_js_1.POSTING_VERSION_BUMP, useValue: postingVersionBump },
|
|
213
|
+
// Grant-admin stack — all over the ONE shared authzPool.
|
|
214
|
+
{ provide: access_bundle_store_js_1.AccessBundleStore, useValue: accessBundleStore },
|
|
215
|
+
{ provide: grant_admin_service_js_1.GrantAdminService, useValue: grantAdminService },
|
|
216
|
+
// Audit read stack — built at forRoot() over the ONE shared authzPool, provided as a
|
|
217
|
+
// useValue (like GrantAdminService) so it carries the hand-built reader/resolver/lookup.
|
|
218
|
+
{ provide: audit_read_service_js_1.AuditReadService, useValue: auditReadService },
|
|
219
|
+
// Cross-tenant grant stack (Phase 6) — the writer + read service, both over the ONE
|
|
220
|
+
// shared authzPool, provided useValue so the controller injects them with no consumer
|
|
221
|
+
// wiring (the out-of-the-box acceptance bar).
|
|
222
|
+
{ provide: guard_1.SHIELD_CROSS_TENANT_GRANT_WRITER, useValue: crossTenantGrantWriter },
|
|
223
|
+
{ provide: cross_tenant_grant_read_service_js_1.CrossTenantGrantReadService, useValue: crossTenantGrantReadService },
|
|
224
|
+
// The mutating half of auto-catalog: a single CatalogReconcileJob (post-boot pass +
|
|
225
|
+
// cron), over the shared pool + the identity registry.
|
|
226
|
+
{ provide: catalog_reconcile_job_js_1.CatalogReconcileJob, useValue: new catalog_reconcile_job_js_1.CatalogReconcileJob(authzPool, identityRegistry) },
|
|
227
|
+
// The bundle half: reconciles the merged AccessBundleSpec[] (ENGINE_ALL_ACCESS_BUNDLES,
|
|
228
|
+
// reflected by the engine from identityAccessBundles) into shield_authz.access_bundle.
|
|
229
|
+
{
|
|
230
|
+
provide: access_bundle_reconcile_job_js_1.AccessBundleReconcileJob,
|
|
231
|
+
useFactory: (bundles) => new access_bundle_reconcile_job_js_1.AccessBundleReconcileJob(authzPool, bundles),
|
|
232
|
+
inject: [engine_1.ENGINE_ALL_ACCESS_BUNDLES],
|
|
233
|
+
},
|
|
234
|
+
// Boot: read-only verifyGatable (fail-closed). Post-boot: kicks the jobs once (catalog
|
|
235
|
+
// THEN bundles) + schedules them (DISABLE_ALL_CRONS-gated). Injects DiscoveryService/
|
|
236
|
+
// MetadataScanner/Reflector (from DiscoveryModule above), the two jobs, and
|
|
237
|
+
// ENGINE_ALL_REGISTRATIONS (exported by EngineModule).
|
|
238
|
+
catalog_bootstrap_provider_js_1.CatalogBootstrapProvider,
|
|
239
|
+
],
|
|
240
|
+
exports: [company_service_js_1.CompanyService, role_service_js_1.RoleService, user_service_js_1.UserService],
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
exports.IdentityFeatureModule = IdentityFeatureModule;
|
|
245
|
+
exports.IdentityFeatureModule = IdentityFeatureModule = IdentityFeatureModule_1 = __decorate([
|
|
246
|
+
(0, common_1.Module)({})
|
|
247
|
+
], IdentityFeatureModule);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { MembershipService } from './membership.service.js';
|
|
2
|
+
import { PostingService, type PostingInput } from './posting.service.js';
|
|
3
|
+
import { RecordResolverService } from '../shared/record-resolver.js';
|
|
4
|
+
/** A membership card for the API, with EVERY uuid resolved to a display name. */
|
|
5
|
+
interface MembershipCardDto {
|
|
6
|
+
/** The membership id — used as a route param / react key, NEVER as a display field. */
|
|
7
|
+
membershipUuid: string;
|
|
8
|
+
companyUuid: string;
|
|
9
|
+
companyName: string;
|
|
10
|
+
isActive: boolean;
|
|
11
|
+
status: string;
|
|
12
|
+
postings: PostingCardDto[];
|
|
13
|
+
}
|
|
14
|
+
/** A posting for the API, with role/dept/branch uuids resolved to names. */
|
|
15
|
+
interface PostingCardDto {
|
|
16
|
+
postingUuid: string;
|
|
17
|
+
roleUuid: string;
|
|
18
|
+
roleName: string;
|
|
19
|
+
departmentUuid: string | null;
|
|
20
|
+
departmentName: string | null;
|
|
21
|
+
branchUuid: string | null;
|
|
22
|
+
branchName: string | null;
|
|
23
|
+
/** Effective-window bounds as epoch-ms (BIGINT storage), or null = open-ended. */
|
|
24
|
+
effectiveFrom: number | null;
|
|
25
|
+
effectiveUntil: number | null;
|
|
26
|
+
}
|
|
27
|
+
export declare class MembershipController {
|
|
28
|
+
private readonly memberships;
|
|
29
|
+
private readonly postings;
|
|
30
|
+
private readonly resolver;
|
|
31
|
+
constructor(memberships: MembershipService, postings: PostingService, resolver: RecordResolverService);
|
|
32
|
+
/**
|
|
33
|
+
* The user's membership cards + their postings, with EVERY uuid resolved to a display
|
|
34
|
+
* NAME (companyName / roleName / departmentName / branchName). Raw `*_uuid` values are
|
|
35
|
+
* returned ONLY as ids/params for the client to key/route on — never as a display
|
|
36
|
+
* field. This is the non-negotiable no-raw-UUID rule enforced server-side.
|
|
37
|
+
*/
|
|
38
|
+
listUserMemberships(userUuid: string): Promise<{
|
|
39
|
+
memberships: MembershipCardDto[];
|
|
40
|
+
}>;
|
|
41
|
+
/** Add the user to a company (creates an inactive membership card). */
|
|
42
|
+
addUserMembership(userUuid: string, body: {
|
|
43
|
+
companyUuid?: string;
|
|
44
|
+
}): Promise<{
|
|
45
|
+
id: import("@astram/engine").RecordId;
|
|
46
|
+
}>;
|
|
47
|
+
/**
|
|
48
|
+
* Update a membership — `isActive` flips activation through `setActive` (the one-active
|
|
49
|
+
* invariant); `status` is a plain field update. Activating requires the owning user so
|
|
50
|
+
* the invariant can deactivate the user's OTHER memberships.
|
|
51
|
+
*/
|
|
52
|
+
updateMembership(membershipUuid: string, body: {
|
|
53
|
+
isActive?: boolean;
|
|
54
|
+
status?: string;
|
|
55
|
+
userUuid?: string;
|
|
56
|
+
}): Promise<{
|
|
57
|
+
success: boolean;
|
|
58
|
+
}>;
|
|
59
|
+
/** Remove a membership card (its postings cascade at the DB). */
|
|
60
|
+
removeMembership(membershipUuid: string): Promise<{
|
|
61
|
+
success: boolean;
|
|
62
|
+
}>;
|
|
63
|
+
/** Atomically replace a membership's full posting set (the diff sync). */
|
|
64
|
+
replacePostings(membershipUuid: string, body: {
|
|
65
|
+
postings?: PostingInput[];
|
|
66
|
+
}): Promise<{
|
|
67
|
+
success: boolean;
|
|
68
|
+
}>;
|
|
69
|
+
/**
|
|
70
|
+
* Resolve a record's display name via the RecordsService facade (`resolver.getRecord`), falling back to
|
|
71
|
+
* `(unknown)` for a missing/deleted record — NEVER leaking the uuid as the label. Used
|
|
72
|
+
* only to build human-readable membership/posting cards (rule: no raw UUIDs in the UI).
|
|
73
|
+
*/
|
|
74
|
+
private resolveName;
|
|
75
|
+
}
|
|
76
|
+
export {};
|
|
77
|
+
//# sourceMappingURL=membership.controller.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"membership.controller.d.ts","sourceRoot":"","sources":["../../../src/features/membership/membership.controller.ts"],"names":[],"mappings":"AAGA,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;AAGrE,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;AASD,qBACa,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,CACF,QAAQ,EAAE,MAAM,EAC3B,IAAI,EAAE;QAAE,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE;;;IAMxC;;;;OAIG;IAGG,gBAAgB,CACK,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;;;IAc1E,iEAAiE;IAG3D,gBAAgB,CAA0B,cAAc,EAAE,MAAM;;;IAKtE,0EAA0E;IAGpE,eAAe,CACM,cAAc,EAAE,MAAM,EACvC,IAAI,EAAE;QAAE,QAAQ,CAAC,EAAE,YAAY,EAAE,CAAA;KAAE;;;IAM7C;;;;OAIG;YACW,WAAW;CAM1B"}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
9
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
|
+
};
|
|
11
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
12
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.MembershipController = void 0;
|
|
16
|
+
const common_1 = require("@nestjs/common");
|
|
17
|
+
const guard_1 = require("@astram/guard");
|
|
18
|
+
const membership_service_js_1 = require("./membership.service.js");
|
|
19
|
+
const posting_service_js_1 = require("./posting.service.js");
|
|
20
|
+
const record_resolver_js_1 = require("../shared/record-resolver.js");
|
|
21
|
+
const http_js_1 = require("../shared/http.js");
|
|
22
|
+
// ── Memberships + postings ───────────────────────────────────────────────────
|
|
23
|
+
//
|
|
24
|
+
// Gated on the `company_membership` / `membership_posting` grants via `@Classified`
|
|
25
|
+
// + the engine's mandatory in-pipe gate. Both are DATA-plane types, so the capped root
|
|
26
|
+
// is denied them by construction — the membership admin surface is grant-driven, not
|
|
27
|
+
// root-driven. Real per-company-admin scoping is a later permission-engine concern.
|
|
28
|
+
let MembershipController = class MembershipController {
|
|
29
|
+
memberships;
|
|
30
|
+
postings;
|
|
31
|
+
resolver;
|
|
32
|
+
constructor(memberships, postings, resolver) {
|
|
33
|
+
this.memberships = memberships;
|
|
34
|
+
this.postings = postings;
|
|
35
|
+
this.resolver = resolver;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The user's membership cards + their postings, with EVERY uuid resolved to a display
|
|
39
|
+
* NAME (companyName / roleName / departmentName / branchName). Raw `*_uuid` values are
|
|
40
|
+
* returned ONLY as ids/params for the client to key/route on — never as a display
|
|
41
|
+
* field. This is the non-negotiable no-raw-UUID rule enforced server-side.
|
|
42
|
+
*/
|
|
43
|
+
async listUserMemberships(userUuid) {
|
|
44
|
+
const rows = await this.memberships.listMemberships(userUuid);
|
|
45
|
+
const cards = [];
|
|
46
|
+
for (const m of rows) {
|
|
47
|
+
const membershipUuid = typeof m.id === 'string' ? m.id : String(m.id);
|
|
48
|
+
const companyUuid = (0, http_js_1.asUuid)(m.fields.companyUuid) ?? '';
|
|
49
|
+
const postingRows = await this.postings.listPostings(membershipUuid);
|
|
50
|
+
const postings = [];
|
|
51
|
+
for (const p of postingRows) {
|
|
52
|
+
const roleUuid = (0, http_js_1.asUuid)(p.fields.roleUuid) ?? '';
|
|
53
|
+
const departmentUuid = (0, http_js_1.asUuid)(p.fields.departmentUuid);
|
|
54
|
+
const branchUuid = (0, http_js_1.asUuid)(p.fields.branchUuid);
|
|
55
|
+
postings.push({
|
|
56
|
+
postingUuid: typeof p.id === 'string' ? p.id : String(p.id),
|
|
57
|
+
roleUuid,
|
|
58
|
+
roleName: await this.resolveName('role', roleUuid),
|
|
59
|
+
departmentUuid,
|
|
60
|
+
departmentName: departmentUuid
|
|
61
|
+
? await this.resolveName('department', departmentUuid)
|
|
62
|
+
: null,
|
|
63
|
+
branchUuid,
|
|
64
|
+
branchName: branchUuid ? await this.resolveName('branch', branchUuid) : null,
|
|
65
|
+
effectiveFrom: (0, http_js_1.asEpochMs)(p.fields.effectiveFrom),
|
|
66
|
+
effectiveUntil: (0, http_js_1.asEpochMs)(p.fields.effectiveUntil),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
cards.push({
|
|
70
|
+
membershipUuid,
|
|
71
|
+
companyUuid,
|
|
72
|
+
companyName: companyUuid ? await this.resolveName('company', companyUuid) : '(unknown)',
|
|
73
|
+
isActive: Boolean(m.fields.isActive),
|
|
74
|
+
status: typeof m.fields.status === 'string' ? m.fields.status : 'active',
|
|
75
|
+
postings,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return { memberships: cards };
|
|
79
|
+
}
|
|
80
|
+
/** 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 ?? '');
|
|
83
|
+
return { id };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Update a membership — `isActive` flips activation through `setActive` (the one-active
|
|
87
|
+
* invariant); `status` is a plain field update. Activating requires the owning user so
|
|
88
|
+
* the invariant can deactivate the user's OTHER memberships.
|
|
89
|
+
*/
|
|
90
|
+
async updateMembership(membershipUuid, body) {
|
|
91
|
+
if (body?.isActive === true) {
|
|
92
|
+
if (!body.userUuid) {
|
|
93
|
+
throw new common_1.ForbiddenException('userUuid is required to activate a membership');
|
|
94
|
+
}
|
|
95
|
+
await this.memberships.setActive(membershipUuid, body.userUuid);
|
|
96
|
+
}
|
|
97
|
+
if (typeof body?.status === 'string') {
|
|
98
|
+
await this.memberships.updateMembership(membershipUuid, { status: body.status });
|
|
99
|
+
}
|
|
100
|
+
return { success: true };
|
|
101
|
+
}
|
|
102
|
+
/** Remove a membership card (its postings cascade at the DB). */
|
|
103
|
+
async removeMembership(membershipUuid) {
|
|
104
|
+
await this.memberships.removeMembership(membershipUuid);
|
|
105
|
+
return { success: true };
|
|
106
|
+
}
|
|
107
|
+
/** Atomically replace a membership's full posting set (the diff sync). */
|
|
108
|
+
async replacePostings(membershipUuid, body) {
|
|
109
|
+
await this.postings.replacePostings(membershipUuid, body?.postings ?? []);
|
|
110
|
+
return { success: true };
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Resolve a record's display name via the RecordsService facade (`resolver.getRecord`), falling back to
|
|
114
|
+
* `(unknown)` for a missing/deleted record — NEVER leaking the uuid as the label. Used
|
|
115
|
+
* only to build human-readable membership/posting cards (rule: no raw UUIDs in the UI).
|
|
116
|
+
*/
|
|
117
|
+
async resolveName(typeKey, uuid) {
|
|
118
|
+
if (!uuid)
|
|
119
|
+
return '(unknown)';
|
|
120
|
+
const view = await this.resolver.getRecord(typeKey, uuid);
|
|
121
|
+
const name = view?.fields?.name;
|
|
122
|
+
return typeof name === 'string' && name.length > 0 ? name : '(unknown)';
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
exports.MembershipController = MembershipController;
|
|
126
|
+
__decorate([
|
|
127
|
+
(0, common_1.Get)('users/:userUuid/memberships'),
|
|
128
|
+
(0, guard_1.Classified)({ typeKey: 'company_membership', action: 'read' }),
|
|
129
|
+
__param(0, (0, common_1.Param)('userUuid')),
|
|
130
|
+
__metadata("design:type", Function),
|
|
131
|
+
__metadata("design:paramtypes", [String]),
|
|
132
|
+
__metadata("design:returntype", Promise)
|
|
133
|
+
], MembershipController.prototype, "listUserMemberships", null);
|
|
134
|
+
__decorate([
|
|
135
|
+
(0, common_1.Post)('users/:userUuid/memberships'),
|
|
136
|
+
(0, guard_1.Classified)({ typeKey: 'company_membership', action: 'create' }),
|
|
137
|
+
__param(0, (0, common_1.Param)('userUuid')),
|
|
138
|
+
__param(1, (0, common_1.Body)()),
|
|
139
|
+
__metadata("design:type", Function),
|
|
140
|
+
__metadata("design:paramtypes", [String, Object]),
|
|
141
|
+
__metadata("design:returntype", Promise)
|
|
142
|
+
], MembershipController.prototype, "addUserMembership", null);
|
|
143
|
+
__decorate([
|
|
144
|
+
(0, common_1.Put)('memberships/:membershipUuid'),
|
|
145
|
+
(0, guard_1.Classified)({ typeKey: 'company_membership', action: 'update' }),
|
|
146
|
+
__param(0, (0, common_1.Param)('membershipUuid')),
|
|
147
|
+
__param(1, (0, common_1.Body)()),
|
|
148
|
+
__metadata("design:type", Function),
|
|
149
|
+
__metadata("design:paramtypes", [String, Object]),
|
|
150
|
+
__metadata("design:returntype", Promise)
|
|
151
|
+
], MembershipController.prototype, "updateMembership", null);
|
|
152
|
+
__decorate([
|
|
153
|
+
(0, common_1.Delete)('memberships/:membershipUuid'),
|
|
154
|
+
(0, guard_1.Classified)({ typeKey: 'company_membership', action: 'delete' }),
|
|
155
|
+
__param(0, (0, common_1.Param)('membershipUuid')),
|
|
156
|
+
__metadata("design:type", Function),
|
|
157
|
+
__metadata("design:paramtypes", [String]),
|
|
158
|
+
__metadata("design:returntype", Promise)
|
|
159
|
+
], MembershipController.prototype, "removeMembership", null);
|
|
160
|
+
__decorate([
|
|
161
|
+
(0, common_1.Put)('memberships/:membershipUuid/postings'),
|
|
162
|
+
(0, guard_1.Classified)({ typeKey: 'membership_posting', action: 'update' }),
|
|
163
|
+
__param(0, (0, common_1.Param)('membershipUuid')),
|
|
164
|
+
__param(1, (0, common_1.Body)()),
|
|
165
|
+
__metadata("design:type", Function),
|
|
166
|
+
__metadata("design:paramtypes", [String, Object]),
|
|
167
|
+
__metadata("design:returntype", Promise)
|
|
168
|
+
], MembershipController.prototype, "replacePostings", null);
|
|
169
|
+
exports.MembershipController = MembershipController = __decorate([
|
|
170
|
+
(0, common_1.Controller)('identity'),
|
|
171
|
+
__metadata("design:paramtypes", [membership_service_js_1.MembershipService,
|
|
172
|
+
posting_service_js_1.PostingService,
|
|
173
|
+
record_resolver_js_1.RecordResolverService])
|
|
174
|
+
], MembershipController);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MembershipService — the membership layer's write/read logic, built ENTIRELY on the
|
|
3
|
+
* injected RecordsService facade (which supplies the request principal and runs each op
|
|
4
|
+
* through the engine pipe). It never touches a pool, query runner, or raw SQL — every op
|
|
5
|
+
* is one-or-more facade calls, so the permission gate + anti-forgery write-context +
|
|
6
|
+
* audit + cross-tenant isolation are applied automatically.
|
|
7
|
+
*
|
|
8
|
+
* A `company_membership` is the first-class `(user, company)` scope with `is_active`
|
|
9
|
+
* (exactly one active per user) + `status`. This service owns the ONE invariant the DB
|
|
10
|
+
* cannot express — one-active-per-user — because there is no partial-unique on
|
|
11
|
+
* `cm_is_active`; `setActive` is the guard.
|
|
12
|
+
*/
|
|
13
|
+
import { RecordsService, type RecordId, type RecordView } from '@astram/engine';
|
|
14
|
+
import { type PostingVersionBump } from './posting-version-bump.js';
|
|
15
|
+
export declare class MembershipService {
|
|
16
|
+
private readonly records;
|
|
17
|
+
private readonly versionBump?;
|
|
18
|
+
constructor(records: RecordsService, versionBump?: (PostingVersionBump | null) | undefined);
|
|
19
|
+
/**
|
|
20
|
+
* Read a membership's tenant (its `companyUuid`) through the pipe, or `null` when
|
|
21
|
+
* unresolvable (→ no bump; fail-safe, never guesses a tenant). Used to target the
|
|
22
|
+
* permission-version bump at the company whose coarse grants a mutation changed.
|
|
23
|
+
*/
|
|
24
|
+
private tenantOf;
|
|
25
|
+
/**
|
|
26
|
+
* Bump the permission version for each DISTINCT tenant in `tenantUuids` (best-effort +
|
|
27
|
+
* fail-safe): the mutation already succeeded, so a bump hiccup must NOT surface as an
|
|
28
|
+
* error that hides a successful membership change — the cache is invalidated by the next
|
|
29
|
+
* legit bump / the facts' natural TTL instead. No collaborator wired → no-op (prior
|
|
30
|
+
* behavior). `null`/empty entries are skipped (never bump a guessed tenant).
|
|
31
|
+
*/
|
|
32
|
+
private bumpTenants;
|
|
33
|
+
/**
|
|
34
|
+
* Add a membership card for `(userUuid, companyUuid)`. New memberships are created
|
|
35
|
+
* INACTIVE (`isActive:false`) — activation is an explicit `setActive` call so the
|
|
36
|
+
* one-active invariant is never violated by a fresh add. A duplicate `(user, company)`
|
|
37
|
+
* hits the DB's UNIQUE constraint and the engine's error surfaces to the caller (NOT
|
|
38
|
+
* swallowed here). `createdBy` is stamped server-side (actorId-trusted).
|
|
39
|
+
*/
|
|
40
|
+
addMembership(userUuid: string, companyUuid: string): Promise<RecordId>;
|
|
41
|
+
/** List a user's membership cards (tenant-scoped by the pipe). */
|
|
42
|
+
listMemberships(userUuid: string): Promise<readonly RecordView[]>;
|
|
43
|
+
/**
|
|
44
|
+
* Flip which of the user's memberships is active — the ONE-ACTIVE invariant. The service
|
|
45
|
+
* is the guard because the DB has no partial-unique on `cm_is_active`.
|
|
46
|
+
*
|
|
47
|
+
* ⚠️ ORDER IS LOAD-BEARING (fail-safe against a LOGIN LOCKOUT). The engine's `list` has
|
|
48
|
+
* no ORDER BY (PKs are random uuids → arbitrary row order) and each `update` is its OWN
|
|
49
|
+
* transaction (no cross-call atomicity). So this MUST activate the TARGET FIRST, then
|
|
50
|
+
* deactivate the others:
|
|
51
|
+
* - Worst case of a mid-loop failure is then a transient TWO-active window, which is
|
|
52
|
+
* RECOVERABLE — the credential resolver reads `is_active=1 LIMIT 1`, so it still
|
|
53
|
+
* resolves a tenant and login works.
|
|
54
|
+
* - The forbidden alternative (deactivate-old-then-activate-target) can leave a
|
|
55
|
+
* ZERO-active window on a mid-failure → the resolver returns null → the user is
|
|
56
|
+
* LOCKED OUT of login until an admin re-activates. Never do that.
|
|
57
|
+
*
|
|
58
|
+
* Guard: if `membershipUuid` is NOT one of the user's memberships, throw — otherwise the
|
|
59
|
+
* loop would deactivate everything and activate nothing (also a zero-active lockout).
|
|
60
|
+
*/
|
|
61
|
+
setActive(membershipUuid: string, userUuid: string): Promise<void>;
|
|
62
|
+
/**
|
|
63
|
+
* Update a membership's mutable non-activity fields (currently `status`). Activity is
|
|
64
|
+
* flipped only via `setActive` to preserve the one-active invariant, so `isActive` is
|
|
65
|
+
* intentionally NOT a patch key here.
|
|
66
|
+
*/
|
|
67
|
+
updateMembership(membershipUuid: string, patch: {
|
|
68
|
+
status?: string;
|
|
69
|
+
}): Promise<void>;
|
|
70
|
+
/** Remove a membership card (its postings cascade at the DB via FK ON DELETE CASCADE). */
|
|
71
|
+
removeMembership(membershipUuid: string): Promise<void>;
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=membership.service.d.ts.map
|
|
@@ -0,0 +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;gBARZ,OAAO,EAAE,cAAc,EAQvB,WAAW,CAAC,GAAE,kBAAkB,GAAG,IAAI,aAAA;IAG1D;;;;OAIG;YACW,QAAQ;IAUtB;;;;;;OAMG;YACW,WAAW;IAYzB;;;;;;OAMG;IACH,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC;IAMvE,kEAAkE;IAC5D,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,UAAU,EAAE,CAAC;IAKvE;;;;;;;;;;;;;;;;;OAiBG;IACG,SAAS,CAAC,cAAc,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAqCxE;;;;OAIG;IACH,gBAAgB,CAAC,cAAc,EAAE,MAAM,EAAE,KAAK,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAWnF,0FAA0F;IACpF,gBAAgB,CAAC,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAU9D"}
|