@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.
Files changed (118) hide show
  1. package/dist/company-membership.object.d.ts.map +1 -1
  2. package/dist/company-membership.object.js +3 -2
  3. package/dist/company.object.d.ts.map +1 -1
  4. package/dist/company.object.js +8 -3
  5. package/dist/features/access/index.d.ts.map +1 -1
  6. package/dist/features/access/index.js +1 -5
  7. package/dist/features/audit/audit-read.service.d.ts.map +1 -1
  8. package/dist/features/audit/audit-read.service.js +13 -6
  9. package/dist/features/audit/audit.controller.d.ts.map +1 -1
  10. package/dist/features/audit/audit.controller.js +2 -0
  11. package/dist/features/audit/mariadb-audit-reader.d.ts.map +1 -1
  12. package/dist/features/authz/access-bundle-reconcile.job.d.ts.map +1 -1
  13. package/dist/features/authz/catalog-reconcile.job.d.ts.map +1 -1
  14. package/dist/features/authz/catalog-reconcile.job.js +1 -3
  15. package/dist/features/authz/db-permission-facts.adapter.d.ts +24 -4
  16. package/dist/features/authz/db-permission-facts.adapter.d.ts.map +1 -1
  17. package/dist/features/authz/db-permission-facts.adapter.js +92 -5
  18. package/dist/features/authz/grant-admin.controller.d.ts +8 -5
  19. package/dist/features/authz/grant-admin.controller.d.ts.map +1 -1
  20. package/dist/features/authz/grant-admin.controller.js +22 -15
  21. package/dist/features/authz/grant-admin.service.d.ts +57 -11
  22. package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
  23. package/dist/features/authz/grant-admin.service.js +163 -85
  24. package/dist/features/authz/grant-ceiling.d.ts +111 -0
  25. package/dist/features/authz/grant-ceiling.d.ts.map +1 -0
  26. package/dist/features/authz/grant-ceiling.js +248 -0
  27. package/dist/features/authz/mariadb-catalog-store.d.ts.map +1 -1
  28. package/dist/features/authz/mariadb-catalog-store.js +15 -3
  29. package/dist/features/authz/permission-version.bumper.d.ts +45 -1
  30. package/dist/features/authz/permission-version.bumper.d.ts.map +1 -1
  31. package/dist/features/authz/permission-version.bumper.js +106 -16
  32. package/dist/features/authz/record-authorization.d.ts.map +1 -1
  33. package/dist/features/authz/role-binding.d.ts +36 -0
  34. package/dist/features/authz/role-binding.d.ts.map +1 -0
  35. package/dist/features/authz/role-binding.js +53 -0
  36. package/dist/features/company/company-hierarchy.service.d.ts +46 -0
  37. package/dist/features/company/company-hierarchy.service.d.ts.map +1 -0
  38. package/dist/features/company/company-hierarchy.service.js +100 -0
  39. package/dist/features/company/company.controller.d.ts +23 -5
  40. package/dist/features/company/company.controller.d.ts.map +1 -1
  41. package/dist/features/company/company.controller.js +47 -12
  42. package/dist/features/company/company.service.d.ts +28 -12
  43. package/dist/features/company/company.service.d.ts.map +1 -1
  44. package/dist/features/company/company.service.js +61 -14
  45. package/dist/features/cross-tenant/cross-tenant-grant.controller.d.ts.map +1 -1
  46. package/dist/features/cross-tenant/cross-tenant-grant.controller.js +2 -0
  47. package/dist/features/identity-feature.module.d.ts.map +1 -1
  48. package/dist/features/identity-feature.module.js +123 -6
  49. package/dist/features/membership/membership.controller.d.ts +6 -5
  50. package/dist/features/membership/membership.controller.d.ts.map +1 -1
  51. package/dist/features/membership/membership.controller.js +34 -22
  52. package/dist/features/membership/membership.service.d.ts +20 -14
  53. package/dist/features/membership/membership.service.d.ts.map +1 -1
  54. package/dist/features/membership/membership.service.js +62 -29
  55. package/dist/features/membership/posting-version-bump.d.ts +5 -0
  56. package/dist/features/membership/posting-version-bump.d.ts.map +1 -1
  57. package/dist/features/membership/posting.service.d.ts +20 -24
  58. package/dist/features/membership/posting.service.d.ts.map +1 -1
  59. package/dist/features/membership/posting.service.js +101 -64
  60. package/dist/features/org-structure/org-catalog.controller.d.ts.map +1 -1
  61. package/dist/features/org-structure/org-catalog.controller.js +2 -0
  62. package/dist/features/org-structure/org-catalog.service.d.ts.map +1 -1
  63. package/dist/features/org-structure/org-catalog.service.js +4 -1
  64. package/dist/features/policy/policy.controller.d.ts.map +1 -1
  65. package/dist/features/policy/policy.controller.js +2 -0
  66. package/dist/features/role/role.controller.d.ts +3 -2
  67. package/dist/features/role/role.controller.d.ts.map +1 -1
  68. package/dist/features/role/role.controller.js +15 -10
  69. package/dist/features/role/role.service.d.ts +16 -8
  70. package/dist/features/role/role.service.d.ts.map +1 -1
  71. package/dist/features/role/role.service.js +24 -10
  72. package/dist/features/scope/scope-closure.service.d.ts +44 -4
  73. package/dist/features/scope/scope-closure.service.d.ts.map +1 -1
  74. package/dist/features/scope/scope-closure.service.js +215 -82
  75. package/dist/features/service-account/service-account.controller.d.ts +63 -0
  76. package/dist/features/service-account/service-account.controller.d.ts.map +1 -0
  77. package/dist/features/service-account/service-account.controller.js +295 -0
  78. package/dist/features/service-account/service-account.service.d.ts +209 -0
  79. package/dist/features/service-account/service-account.service.d.ts.map +1 -0
  80. package/dist/features/service-account/service-account.service.js +528 -0
  81. package/dist/features/service-account/service-account.store.d.ts +91 -0
  82. package/dist/features/service-account/service-account.store.d.ts.map +1 -0
  83. package/dist/features/service-account/service-account.store.js +158 -0
  84. package/dist/features/share/share.controller.d.ts.map +1 -1
  85. package/dist/features/share/share.controller.js +2 -0
  86. package/dist/features/share/share.service.d.ts.map +1 -1
  87. package/dist/features/shared/identity-error.interceptor.d.ts +24 -0
  88. package/dist/features/shared/identity-error.interceptor.d.ts.map +1 -0
  89. package/dist/features/shared/identity-error.interceptor.js +82 -0
  90. package/dist/features/shared/identity-errors.d.ts +23 -0
  91. package/dist/features/shared/identity-errors.d.ts.map +1 -0
  92. package/dist/features/shared/identity-errors.js +41 -0
  93. package/dist/features/user/session-revocation-check.d.ts +11 -0
  94. package/dist/features/user/session-revocation-check.d.ts.map +1 -0
  95. package/dist/features/user/session-revocation-check.js +21 -0
  96. package/dist/features/user/user.controller.d.ts +11 -6
  97. package/dist/features/user/user.controller.d.ts.map +1 -1
  98. package/dist/features/user/user.controller.js +35 -17
  99. package/dist/features/user/user.service.d.ts +55 -14
  100. package/dist/features/user/user.service.d.ts.map +1 -1
  101. package/dist/features/user/user.service.js +143 -16
  102. package/dist/index.d.ts +1 -0
  103. package/dist/index.d.ts.map +1 -1
  104. package/dist/index.js +3 -1
  105. package/dist/membership-posting.object.d.ts.map +1 -1
  106. package/dist/membership-posting.object.js +4 -2
  107. package/dist/objects.d.ts.map +1 -1
  108. package/dist/objects.js +14 -3
  109. package/dist/role.object.d.ts.map +1 -1
  110. package/dist/role.object.js +3 -0
  111. package/dist/service-account.object.d.ts +31 -0
  112. package/dist/service-account.object.d.ts.map +1 -0
  113. package/dist/service-account.object.js +75 -0
  114. package/dist/user.object.d.ts.map +1 -1
  115. package/dist/user.object.js +3 -0
  116. package/migrations/1710000000018-CreateServiceAccount.cjs +53 -0
  117. package/migrations/1710000000019-AddSessionMustReenroll.cjs +35 -0
  118. package/package.json +4 -4
@@ -1,26 +1,67 @@
1
1
  import { RecordsService, type RecordId } from '@astram/engine';
2
+ import type { Principal } from '@astram/core';
3
+ import { type EmailSender, type ResetTokenStore, type SessionSweeper } from '@astram/guard';
4
+ import { type PostingVersionBump } from '../membership/posting-version-bump.js';
2
5
  import { type ListParams } from '../shared/list-query.js';
6
+ import { GrantCeiling } from '../authz/grant-ceiling.js';
7
+ /** The patch keys a generic user update accepts (security hardening #3). */
8
+ export interface UserPatch {
9
+ username?: unknown;
10
+ email?: unknown;
11
+ status?: unknown;
12
+ }
3
13
  /**
4
- * UserService — user (principal) CRUD on the paved road, via the framework's
5
- * RecordsService facade. The facade supplies the acting identity (the request
6
- * principal) — this service never threads a Principal. No raw SQL, no RECORD_ENGINE.
14
+ * UserService — user (principal) CRUD on the paved road, via the framework's RecordsService facade
15
+ * (the request principal acts). No raw SQL, no RECORD_ENGINE.
16
+ *
17
+ * Users are GLOBAL (one user can belong to many tenants) and the email is a credential attribute:
18
+ * forgot-password mails the reset link to it. So (security hardening #3, D2):
19
+ * - the patch is whitelisted to `{username, email, status}`;
20
+ * - changing `email` or `status`, and deleting a user, require the actor to DOMINATE the target
21
+ * (`GrantCeiling.assertDominates`: every membership of the target inside the actor's subtree,
22
+ * and every key the target could hold is held by the actor);
23
+ * - an email change burns the target's outstanding password-reset tokens BEFORE the change and
24
+ * notifies the OLD address after it (best-effort), when guard's reset-token store and email
25
+ * sender are wired (`ShieldModule.forRoot({ auth: true })`).
7
26
  */
8
27
  export declare class UserService {
9
28
  private readonly records;
10
- constructor(records: RecordsService);
11
- /** Create a user. */
12
- createUser(username: string): Promise<RecordId>;
29
+ private readonly ceiling;
30
+ private readonly resetTokens?;
31
+ private readonly emailSender?;
32
+ private readonly sessions?;
33
+ private readonly versionBump?;
34
+ private readonly logger;
35
+ constructor(records: RecordsService, ceiling: GrantCeiling, resetTokens?: (ResetTokenStore | null) | undefined, emailSender?: (EmailSender | null) | undefined, sessions?: (SessionSweeper | null) | undefined, versionBump?: (PostingVersionBump | null) | undefined);
36
+ /**
37
+ * Create a user. With `companyUuid`, the user is created AND attached (one active membership) in
38
+ * ONE transaction — the onboarding path for a non-root admin, whose membership create is checked
39
+ * by the engine's create wall (a company outside the caller's subtree ⇒ 403, nothing persists).
40
+ */
41
+ createUser(actor: Principal, input: {
42
+ username: string;
43
+ companyUuid?: string | undefined;
44
+ }): Promise<RecordId>;
13
45
  /** List users (tenant-scoped by the pipe), with paging/search/sort. */
14
46
  listUsers(params?: ListParams): Promise<import("@astram/engine").RecordPage>;
15
47
  /** Read a single user. */
16
48
  getUser(uuid: string): Promise<import("@astram/engine").RecordView | null>;
17
- /** Update a user. */
18
- updateUser(uuid: string, patch: {
19
- username?: string;
20
- email?: string;
21
- status?: string;
22
- }): Promise<void>;
23
- /** Delete a user. */
24
- deleteUser(uuid: string): Promise<void>;
49
+ /** Update a user (whitelisted patch; email/status changes need dominance — see the class doc). */
50
+ updateUser(actor: Principal, uuid: string, patch: UserPatch): Promise<void>;
51
+ /**
52
+ * After a status change (security hardening Phase 3): bump every tenant the user is a member of
53
+ * (the next request reloads its grants: none while disabled, back when re-enabled), and, when the
54
+ * account is not `active`, durably revoke all its sessions (Redis unbound too).
55
+ *
56
+ * Both are ATTEMPTED INDEPENDENTLY (Phase 3 review P1): a failed bump never skips the revoke, or
57
+ * the reverse. If either failed, the first error is rethrown, so the admin sees it and can
58
+ * re-send the same status (which re-runs both). The status itself is already committed, so login
59
+ * and role reads refuse the account regardless.
60
+ */
61
+ private applyStatusChange;
62
+ /** Delete a user — only a user the actor dominates. */
63
+ deleteUser(actor: Principal, uuid: string): Promise<void>;
64
+ /** Tell the OLD address its account email changed (best-effort; never names the new one). */
65
+ private notifyEmailChanged;
25
66
  }
26
67
  //# sourceMappingURL=user.service.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"user.service.d.ts","sourceRoot":"","sources":["../../../src/features/user/user.service.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,KAAK,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC/D,OAAO,EAAkB,KAAK,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAK1E;;;;GAIG;AACH,qBACa,WAAW;IACV,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,cAAc;IAEpD,qBAAqB;IACrB,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC;IAI/C,uEAAuE;IACvE,SAAS,CAAC,MAAM,CAAC,EAAE,UAAU;IAI7B,0BAA0B;IAC1B,OAAO,CAAC,IAAI,EAAE,MAAM;IAIpB,qBAAqB;IACrB,UAAU,CACR,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,GAC5D,OAAO,CAAC,IAAI,CAAC;IAIhB,qBAAqB;IACrB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAGxC"}
1
+ {"version":3,"file":"user.service.d.ts","sourceRoot":"","sources":["../../../src/features/user/user.service.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,KAAK,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC/D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAIL,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,KAAK,cAAc,EACpB,MAAM,eAAe,CAAC;AACvB,OAAO,EAEL,KAAK,kBAAkB,EACxB,MAAM,uCAAuC,CAAC;AAC/C,OAAO,EAAkB,KAAK,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAC1E,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAMzD,4EAA4E;AAC5E,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED;;;;;;;;;;;;;GAaG;AACH,qBACa,WAAW;IAIpB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAGxB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IACY,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAMtE,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC;IAK1B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAnB/B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAgC;gBAGpC,OAAO,EAAE,cAAc,EACvB,OAAO,EAAE,YAAY,EAGrB,WAAW,CAAC,GAAE,eAAe,GAAG,IAAI,aAAA,EACK,WAAW,CAAC,GAAE,WAAW,GAAG,IAAI,aAAA,EAMzE,QAAQ,CAAC,GAAE,cAAc,GAAG,IAAI,aAAA,EAKhC,WAAW,CAAC,GAAE,kBAAkB,GAAG,IAAI,aAAA;IAG1D;;;;OAIG;IACG,UAAU,CACd,KAAK,EAAE,SAAS,EAChB,KAAK,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;KAAE,GAC5D,OAAO,CAAC,QAAQ,CAAC;IAcpB,uEAAuE;IACvE,SAAS,CAAC,MAAM,CAAC,EAAE,UAAU;IAI7B,0BAA0B;IAC1B,OAAO,CAAC,IAAI,EAAE,MAAM;IAIpB,kGAAkG;IAC5F,UAAU,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;IA8BjF;;;;;;;;;OASG;YACW,iBAAiB;IAY/B,uDAAuD;IACjD,UAAU,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAO/D,6FAA6F;YAC/E,kBAAkB;CAkBjC"}
@@ -8,26 +8,68 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
8
8
  var __metadata = (this && this.__metadata) || function (k, v) {
9
9
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
10
  };
11
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
12
+ return function (target, key) { decorator(target, key, paramIndex); }
13
+ };
14
+ var UserService_1;
11
15
  Object.defineProperty(exports, "__esModule", { value: true });
12
16
  exports.UserService = void 0;
13
17
  const common_1 = require("@nestjs/common");
14
18
  const engine_1 = require("@astram/engine");
19
+ const guard_1 = require("@astram/guard");
20
+ const posting_version_bump_js_1 = require("../membership/posting-version-bump.js");
15
21
  const list_query_js_1 = require("../shared/list-query.js");
22
+ const grant_ceiling_js_1 = require("../authz/grant-ceiling.js");
23
+ const identity_errors_js_1 = require("../shared/identity-errors.js");
16
24
  const USER_SEARCH_FIELDS = ['username', 'email'];
17
25
  const USER_SORT_FIELDS = ['username', 'email', 'status', 'createdAt'];
18
26
  /**
19
- * UserService — user (principal) CRUD on the paved road, via the framework's
20
- * RecordsService facade. The facade supplies the acting identity (the request
21
- * principal) — this service never threads a Principal. No raw SQL, no RECORD_ENGINE.
27
+ * UserService — user (principal) CRUD on the paved road, via the framework's RecordsService facade
28
+ * (the request principal acts). No raw SQL, no RECORD_ENGINE.
29
+ *
30
+ * Users are GLOBAL (one user can belong to many tenants) and the email is a credential attribute:
31
+ * forgot-password mails the reset link to it. So (security hardening #3, D2):
32
+ * - the patch is whitelisted to `{username, email, status}`;
33
+ * - changing `email` or `status`, and deleting a user, require the actor to DOMINATE the target
34
+ * (`GrantCeiling.assertDominates`: every membership of the target inside the actor's subtree,
35
+ * and every key the target could hold is held by the actor);
36
+ * - an email change burns the target's outstanding password-reset tokens BEFORE the change and
37
+ * notifies the OLD address after it (best-effort), when guard's reset-token store and email
38
+ * sender are wired (`ShieldModule.forRoot({ auth: true })`).
22
39
  */
23
- let UserService = class UserService {
40
+ let UserService = UserService_1 = class UserService {
24
41
  records;
25
- constructor(records) {
42
+ ceiling;
43
+ resetTokens;
44
+ emailSender;
45
+ sessions;
46
+ versionBump;
47
+ logger = new common_1.Logger(UserService_1.name);
48
+ constructor(records, ceiling, resetTokens, emailSender, sessions, versionBump) {
26
49
  this.records = records;
50
+ this.ceiling = ceiling;
51
+ this.resetTokens = resetTokens;
52
+ this.emailSender = emailSender;
53
+ this.sessions = sessions;
54
+ this.versionBump = versionBump;
27
55
  }
28
- /** Create a user. */
29
- createUser(username) {
30
- return this.records.create('user', { fields: { username } });
56
+ /**
57
+ * Create a user. With `companyUuid`, the user is created AND attached (one active membership) in
58
+ * ONE transaction — the onboarding path for a non-root admin, whose membership create is checked
59
+ * by the engine's create wall (a company outside the caller's subtree ⇒ 403, nothing persists).
60
+ */
61
+ async createUser(actor, input) {
62
+ if (!input.companyUuid) {
63
+ return this.records.create('user', { fields: { username: input.username } });
64
+ }
65
+ const companyUuid = input.companyUuid;
66
+ return this.records.transaction(async (tx) => {
67
+ const id = await tx.create('user', { fields: { username: input.username } });
68
+ await tx.create('company_membership', {
69
+ fields: { userUuid: id, companyUuid, isActive: true, status: 'active' },
70
+ });
71
+ return id;
72
+ });
31
73
  }
32
74
  /** List users (tenant-scoped by the pipe), with paging/search/sort. */
33
75
  listUsers(params) {
@@ -37,17 +79,102 @@ let UserService = class UserService {
37
79
  getUser(uuid) {
38
80
  return this.records.get('user', uuid);
39
81
  }
40
- /** Update a user. */
41
- updateUser(uuid, patch) {
42
- return this.records.update('user', uuid, { fields: patch });
82
+ /** Update a user (whitelisted patch; email/status changes need dominance — see the class doc). */
83
+ async updateUser(actor, uuid, patch) {
84
+ const current = await this.records.get('user', uuid);
85
+ if (current === null)
86
+ throw new identity_errors_js_1.IdentityNotFoundError('User not found.');
87
+ const fields = {};
88
+ if (typeof patch?.username === 'string')
89
+ fields.username = patch.username;
90
+ const oldEmail = typeof current.fields.email === 'string' ? current.fields.email : '';
91
+ const emailChanged = typeof patch?.email === 'string' && patch.email !== oldEmail;
92
+ const statusChanged = typeof patch?.status === 'string' && patch.status !== current.fields.status;
93
+ // A request for a non-active status ALWAYS runs the follow-up (Phase 3 review P1), even when
94
+ // the stored status already matches: re-sending "disable" repairs a disable whose bump or
95
+ // session revoke failed earlier (idempotent).
96
+ const wantsInactive = typeof patch?.status === 'string' && patch.status !== 'active';
97
+ if (emailChanged)
98
+ fields.email = patch.email;
99
+ if (statusChanged)
100
+ fields.status = patch.status;
101
+ if (emailChanged || statusChanged || wantsInactive) {
102
+ await this.ceiling.assertDominates(actor, uuid);
103
+ }
104
+ if (Object.keys(fields).length > 0) {
105
+ // Burn outstanding reset links BEFORE the address changes (a failure aborts the change).
106
+ if (emailChanged && this.resetTokens)
107
+ await this.resetTokens.invalidateAllForPrincipal(uuid);
108
+ await this.records.update('user', uuid, { fields });
109
+ }
110
+ if (statusChanged || wantsInactive)
111
+ await this.applyStatusChange(uuid, String(patch.status));
112
+ if (emailChanged && oldEmail)
113
+ await this.notifyEmailChanged(oldEmail);
114
+ }
115
+ /**
116
+ * After a status change (security hardening Phase 3): bump every tenant the user is a member of
117
+ * (the next request reloads its grants: none while disabled, back when re-enabled), and, when the
118
+ * account is not `active`, durably revoke all its sessions (Redis unbound too).
119
+ *
120
+ * Both are ATTEMPTED INDEPENDENTLY (Phase 3 review P1): a failed bump never skips the revoke, or
121
+ * the reverse. If either failed, the first error is rethrown, so the admin sees it and can
122
+ * re-send the same status (which re-runs both). The status itself is already committed, so login
123
+ * and role reads refuse the account regardless.
124
+ */
125
+ async applyStatusChange(uuid, status) {
126
+ const steps = [];
127
+ if (this.versionBump?.bumpForUser)
128
+ steps.push(this.versionBump.bumpForUser(uuid));
129
+ if (status !== 'active' && this.sessions)
130
+ steps.push(this.sessions.revokeAllForPrincipal(uuid));
131
+ const results = await Promise.allSettled(steps);
132
+ const failed = results.find((r) => r.status === 'rejected');
133
+ if (failed !== undefined) {
134
+ this.logger.warn('user status follow-up failed; re-send the status to retry');
135
+ throw failed.reason;
136
+ }
137
+ }
138
+ /** Delete a user — only a user the actor dominates. */
139
+ async deleteUser(actor, uuid) {
140
+ const current = await this.records.get('user', uuid);
141
+ if (current === null)
142
+ throw new identity_errors_js_1.IdentityNotFoundError('User not found.');
143
+ await this.ceiling.assertDominates(actor, uuid);
144
+ await this.records.delete('user', uuid);
43
145
  }
44
- /** Delete a user. */
45
- deleteUser(uuid) {
46
- return this.records.delete('user', uuid);
146
+ /** Tell the OLD address its account email changed (best-effort; never names the new one). */
147
+ async notifyEmailChanged(oldEmail) {
148
+ if (!this.emailSender)
149
+ return;
150
+ const text = 'The email address on your account was changed by an administrator. ' +
151
+ 'Password-reset links sent before this change no longer work. ' +
152
+ 'If you did not expect this, contact your administrator.';
153
+ try {
154
+ await this.emailSender.send({
155
+ to: oldEmail,
156
+ subject: 'Your account email address was changed',
157
+ text,
158
+ html: `<p>${text}</p>`,
159
+ });
160
+ }
161
+ catch {
162
+ // Best-effort: the change is committed; a delivery failure must not fail it.
163
+ this.logger.warn('email-change notice could not be delivered');
164
+ }
47
165
  }
48
166
  };
49
167
  exports.UserService = UserService;
50
- exports.UserService = UserService = __decorate([
168
+ exports.UserService = UserService = UserService_1 = __decorate([
51
169
  (0, common_1.Injectable)(),
52
- __metadata("design:paramtypes", [engine_1.RecordsService])
170
+ __param(2, (0, common_1.Optional)()),
171
+ __param(2, (0, common_1.Inject)(guard_1.SHIELD_RESET_TOKEN_STORE)),
172
+ __param(3, (0, common_1.Optional)()),
173
+ __param(3, (0, common_1.Inject)(guard_1.SHIELD_EMAIL_SENDER)),
174
+ __param(4, (0, common_1.Optional)()),
175
+ __param(4, (0, common_1.Inject)(guard_1.SHIELD_SESSION_SWEEPER)),
176
+ __param(5, (0, common_1.Optional)()),
177
+ __param(5, (0, common_1.Inject)(posting_version_bump_js_1.POSTING_VERSION_BUMP)),
178
+ __metadata("design:paramtypes", [engine_1.RecordsService,
179
+ grant_ceiling_js_1.GrantCeiling, Object, Object, Object, Object])
53
180
  ], UserService);
package/dist/index.d.ts CHANGED
@@ -13,6 +13,7 @@ export { CompanyMembership } from './company-membership.object.js';
13
13
  export { MembershipPosting } from './membership-posting.object.js';
14
14
  export { CompanyDepartment } from './company-department.object.js';
15
15
  export { CompanyBranch } from './company-branch.object.js';
16
+ export { ServiceAccount } from './service-account.object.js';
16
17
  export { identityMigrationsDir } from './migrations-dir.js';
17
18
  /**
18
19
  * All identity objects, registered with the engine at boot (same set + order as before).
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAC9C,OAAO,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AACxC,OAAO,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AACxC,OAAO,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACpD,OAAO,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAC5C,OAAO,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AACnE,OAAO,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AACnE,OAAO,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AACnE,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAC3D,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAE5D;;;;GAIG;AACH,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAE/C;;;;;;GAMG;AACH,OAAO,EAAE,sBAAsB,EAAE,wBAAwB,EAAE,MAAM,sBAAsB,CAAC;AAExF;;;;;GAKG;AACH,OAAO,EAAE,qBAAqB,EAAE,MAAM,uCAAuC,CAAC;AAE9E;;;;;GAKG;AACH,OAAO,EACL,uBAAuB,EACvB,gCAAgC,GACjC,MAAM,0CAA0C,CAAC;AAElD;;;;GAIG;AACH,OAAO,EAAE,qBAAqB,EAAE,MAAM,0CAA0C,CAAC;AAEjF;;;;;;GAMG;AACH,OAAO,EAAE,sBAAsB,EAAE,MAAM,4CAA4C,CAAC;AAEpF;;;;;;;GAOG;AACH,OAAO,EAAE,mBAAmB,EAAE,MAAM,6CAA6C,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAC9C,OAAO,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AACxC,OAAO,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AACxC,OAAO,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACpD,OAAO,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAC5C,OAAO,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AACnE,OAAO,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AACnE,OAAO,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AACnE,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAC7D,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAE5D;;;;GAIG;AACH,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAE/C;;;;;;GAMG;AACH,OAAO,EAAE,sBAAsB,EAAE,wBAAwB,EAAE,MAAM,sBAAsB,CAAC;AAExF;;;;;GAKG;AACH,OAAO,EAAE,qBAAqB,EAAE,MAAM,uCAAuC,CAAC;AAE9E;;;;;GAKG;AACH,OAAO,EACL,uBAAuB,EACvB,gCAAgC,GACjC,MAAM,0CAA0C,CAAC;AAElD;;;;GAIG;AACH,OAAO,EAAE,qBAAqB,EAAE,MAAM,0CAA0C,CAAC;AAEjF;;;;;;GAMG;AACH,OAAO,EAAE,sBAAsB,EAAE,MAAM,4CAA4C,CAAC;AAEpF;;;;;;;GAOG;AACH,OAAO,EAAE,mBAAmB,EAAE,MAAM,6CAA6C,CAAC"}
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DbMembershipChecker = exports.DbScopeSubtreeResolver = exports.AlsSessionFactsReader = exports.buildIdentityRecordAuthorization = exports.createIdentityAuthzPool = exports.IdentityFeatureModule = exports.identityControlPlaneKeys = exports.identityAuthActionKeys = exports.identityObjects = exports.identityMigrationsDir = exports.CompanyBranch = exports.CompanyDepartment = exports.MembershipPosting = exports.CompanyMembership = exports.Branch = exports.Department = exports.Role = exports.User = exports.Company = void 0;
3
+ exports.DbMembershipChecker = exports.DbScopeSubtreeResolver = exports.AlsSessionFactsReader = exports.buildIdentityRecordAuthorization = exports.createIdentityAuthzPool = exports.IdentityFeatureModule = exports.identityControlPlaneKeys = exports.identityAuthActionKeys = exports.identityObjects = exports.identityMigrationsDir = exports.ServiceAccount = exports.CompanyBranch = exports.CompanyDepartment = exports.MembershipPosting = exports.CompanyMembership = exports.Branch = exports.Department = exports.Role = exports.User = exports.Company = void 0;
4
4
  /**
5
5
  * @astram/identity — the framework-owned identity + tenancy model.
6
6
  * The 9 identity object classes (relocated from the consumer app in Sub-plan B) +
@@ -25,6 +25,8 @@ var company_department_object_js_1 = require("./company-department.object.js");
25
25
  Object.defineProperty(exports, "CompanyDepartment", { enumerable: true, get: function () { return company_department_object_js_1.CompanyDepartment; } });
26
26
  var company_branch_object_js_1 = require("./company-branch.object.js");
27
27
  Object.defineProperty(exports, "CompanyBranch", { enumerable: true, get: function () { return company_branch_object_js_1.CompanyBranch; } });
28
+ var service_account_object_js_1 = require("./service-account.object.js");
29
+ Object.defineProperty(exports, "ServiceAccount", { enumerable: true, get: function () { return service_account_object_js_1.ServiceAccount; } });
28
30
  var migrations_dir_js_1 = require("./migrations-dir.js");
29
31
  Object.defineProperty(exports, "identityMigrationsDir", { enumerable: true, get: function () { return migrations_dir_js_1.identityMigrationsDir; } });
30
32
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"membership-posting.object.d.ts","sourceRoot":"","sources":["../src/membership-posting.object.ts"],"names":[],"mappings":"AAGA;;;;GAIG;AACH,qBAiBa,iBAAiB;IAE5B,IAAI,EAAG,MAAM,CAAC;IAGd,cAAc,EAAG,MAAM,CAAC;IAGxB,QAAQ,EAAG,MAAM,CAAC;IAGlB,cAAc,EAAG,MAAM,CAAC;IAGxB,UAAU,EAAG,MAAM,CAAC;IAQpB,aAAa,EAAG,MAAM,CAAC;IAGvB,cAAc,EAAG,MAAM,CAAC;IAGxB,SAAS,EAAG,MAAM,CAAC;CACpB"}
1
+ {"version":3,"file":"membership-posting.object.d.ts","sourceRoot":"","sources":["../src/membership-posting.object.ts"],"names":[],"mappings":"AAGA;;;;GAIG;AACH,qBAiBa,iBAAiB;IAE5B,IAAI,EAAG,MAAM,CAAC;IAKd,cAAc,EAAG,MAAM,CAAC;IAGxB,QAAQ,EAAG,MAAM,CAAC;IAGlB,cAAc,EAAG,MAAM,CAAC;IAGxB,UAAU,EAAG,MAAM,CAAC;IAQpB,aAAa,EAAG,MAAM,CAAC;IAGvB,cAAc,EAAG,MAAM,CAAC;IAGxB,SAAS,EAAG,MAAM,CAAC;CACpB"}
@@ -19,6 +19,8 @@ const schema_1 = require("./schema");
19
19
  */
20
20
  let MembershipPosting = class MembershipPosting {
21
21
  uuid;
22
+ // immutable (ADR-0034): a posting never moves to another membership or role after create
23
+ // (a role change is a replace: delete + checked create).
22
24
  membershipUuid;
23
25
  roleUuid;
24
26
  departmentUuid;
@@ -38,11 +40,11 @@ __decorate([
38
40
  __metadata("design:type", String)
39
41
  ], MembershipPosting.prototype, "uuid", void 0);
40
42
  __decorate([
41
- (0, engine_1.Field)({ column: 'mp_membership_uuid', type: 'uuid', required: true }),
43
+ (0, engine_1.Field)({ column: 'mp_membership_uuid', type: 'uuid', required: true, immutable: true }),
42
44
  __metadata("design:type", String)
43
45
  ], MembershipPosting.prototype, "membershipUuid", void 0);
44
46
  __decorate([
45
- (0, engine_1.Field)({ column: 'mp_role_uuid', type: 'uuid', required: true }),
47
+ (0, engine_1.Field)({ column: 'mp_role_uuid', type: 'uuid', required: true, immutable: true }),
46
48
  __metadata("design:type", String)
47
49
  ], MembershipPosting.prototype, "roleUuid", void 0);
48
50
  __decorate([
@@ -1 +1 @@
1
- {"version":3,"file":"objects.d.ts","sourceRoot":"","sources":["../src/objects.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AActD,6FAA6F;AAC7F,eAAO,MAAM,eAAe,EAAE,SAAS,eAAe,EAIrD,CAAC"}
1
+ {"version":3,"file":"objects.d.ts","sourceRoot":"","sources":["../src/objects.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAetD,6FAA6F;AAC7F,eAAO,MAAM,eAAe,EAAE,SAAS,eAAe,EAcrD,CAAC"}
package/dist/objects.js CHANGED
@@ -13,9 +13,20 @@ const company_branch_object_js_1 = require("./company-branch.object.js");
13
13
  const cross_tenant_grant_object_js_1 = require("./cross-tenant-grant.object.js");
14
14
  const record_share_object_js_1 = require("./record-share.object.js");
15
15
  const shared_resource_object_js_1 = require("./shared-resource.object.js");
16
+ const service_account_object_js_1 = require("./service-account.object.js");
16
17
  /** All identity objects, registered with the engine at boot (same set + order as before). */
17
18
  exports.identityObjects = [
18
- company_object_js_1.Company, user_object_js_1.User, role_object_js_1.Role, department_object_js_1.Department, branch_object_js_1.Branch,
19
- company_membership_object_js_1.CompanyMembership, membership_posting_object_js_1.MembershipPosting, company_department_object_js_1.CompanyDepartment, company_branch_object_js_1.CompanyBranch,
20
- cross_tenant_grant_object_js_1.CrossTenantGrant, record_share_object_js_1.RecordShare, shared_resource_object_js_1.SharedResource,
19
+ company_object_js_1.Company,
20
+ user_object_js_1.User,
21
+ role_object_js_1.Role,
22
+ department_object_js_1.Department,
23
+ branch_object_js_1.Branch,
24
+ company_membership_object_js_1.CompanyMembership,
25
+ membership_posting_object_js_1.MembershipPosting,
26
+ company_department_object_js_1.CompanyDepartment,
27
+ company_branch_object_js_1.CompanyBranch,
28
+ cross_tenant_grant_object_js_1.CrossTenantGrant,
29
+ record_share_object_js_1.RecordShare,
30
+ shared_resource_object_js_1.SharedResource,
31
+ service_account_object_js_1.ServiceAccount,
21
32
  ];
@@ -1 +1 @@
1
- {"version":3,"file":"role.object.d.ts","sourceRoot":"","sources":["../src/role.object.ts"],"names":[],"mappings":"AAGA,8BAA8B;AAC9B,qBAiBa,IAAI;IAEf,IAAI,EAAG,MAAM,CAAC;IAGd,IAAI,EAAG,MAAM,CAAC;IAGd,SAAS,EAAG,MAAM,CAAC;CACpB"}
1
+ {"version":3,"file":"role.object.d.ts","sourceRoot":"","sources":["../src/role.object.ts"],"names":[],"mappings":"AAGA,8BAA8B;AAC9B,qBAoBa,IAAI;IAEf,IAAI,EAAG,MAAM,CAAC;IAGd,IAAI,EAAG,MAAM,CAAC;IAGd,SAAS,EAAG,MAAM,CAAC;CACpB"}
@@ -42,8 +42,11 @@ exports.Role = Role = __decorate([
42
42
  // "a role in my subtree" derivation the Task-4.2 grant-admin predicate uses (one consistent
43
43
  // notion). FIXED framework template; the ONLY dynamic values are the `?`-bound subtree node
44
44
  // ids. DECOUPLED from writes. Root spans all.
45
+ // create:'unscoped' (ADR-0034): a NEW role is bound by no posting yet, so the post-insert
46
+ // create wall would deny every create; the posting that binds it IS walled.
45
47
  readScope: {
46
48
  kind: 'subtreeSubquery',
49
+ create: 'unscoped',
47
50
  pkColumn: 'r_uuid',
48
51
  subquerySql: `SELECT mp.mp_role_uuid FROM \`${schema_1.SCHEMA}\`.\`membership_posting\` mp JOIN \`${schema_1.SCHEMA}\`.\`company_membership\` cm ON cm.cm_uuid = mp.mp_membership_uuid WHERE cm.cm_company_uuid IN (${engine_1.READ_SCOPE_NODES_TOKEN})`,
49
52
  },
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `service_account` — a machine principal (ADR-0007 / ADR-0033): the identity an AI agent, an
3
+ * MCP server, or a script authenticates AS (via an `ask_…` API key, whose HASHED versions live in
4
+ * `shield_authz.service_account_key`). One row = one machine account, created + scoped + revoked by
5
+ * a tenant admin from the Service Accounts admin page.
6
+ *
7
+ * `plane: 'control'` — creating/scoping/revoking a service account IS who-may-act administration
8
+ * (the same posture as `user`/`role`/`cross_tenant_grant`): the capped root and a scoped tenant
9
+ * admin may administer them; a non-root admin needs the `identity:service_account:*` grant.
10
+ *
11
+ * `system: true` (never `tenantScoped`) — like `user`/`cross_tenant_grant`, the row-level tenant
12
+ * column (`sa_tenant_id`) plus the store's own tenant-scoped SELECTs + the grant-ceiling enforce
13
+ * isolation; we do NOT route it through the RecordEngine's `tenantScoped` create-stamp baseline
14
+ * (its `ServiceAccountStore` writes it directly, stamping `sa_tenant_id` from the admin's session).
15
+ *
16
+ * `readScope: nodeColumn(sa_tenant_id)` (ADR-0027 / ADR-0030) — the account's own row carries its
17
+ * tenant node, so a NON-ROOT admin's list/read/update/delete is walled to rows whose `sa_tenant_id`
18
+ * is IN their reachable subtree (a cross-tenant id is invisible → 404, no existence leak). Root
19
+ * spans all. This is the read/write wall that makes "own tenant only" structural, not handler-level.
20
+ *
21
+ * Action vocabulary is administration, not CRUD data ops — see `@astram/guard`'s
22
+ * `identity-actions.ts` (`read`/`manage` for `service_account`).
23
+ */
24
+ export declare class ServiceAccount {
25
+ id: string;
26
+ tenantId: string;
27
+ label: string;
28
+ status: string;
29
+ createdAtMs: number;
30
+ }
31
+ //# sourceMappingURL=service-account.object.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-account.object.d.ts","sourceRoot":"","sources":["../src/service-account.object.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,qBAQa,cAAc;IAEzB,EAAE,EAAG,MAAM,CAAC;IAGZ,QAAQ,EAAG,MAAM,CAAC;IAGlB,KAAK,EAAG,MAAM,CAAC;IAGf,MAAM,EAAG,MAAM,CAAC;IAGhB,WAAW,EAAG,MAAM,CAAC;CACtB"}
@@ -0,0 +1,75 @@
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
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.ServiceAccount = void 0;
13
+ const engine_1 = require("@astram/engine");
14
+ const schema_1 = require("./schema");
15
+ /**
16
+ * `service_account` — a machine principal (ADR-0007 / ADR-0033): the identity an AI agent, an
17
+ * MCP server, or a script authenticates AS (via an `ask_…` API key, whose HASHED versions live in
18
+ * `shield_authz.service_account_key`). One row = one machine account, created + scoped + revoked by
19
+ * a tenant admin from the Service Accounts admin page.
20
+ *
21
+ * `plane: 'control'` — creating/scoping/revoking a service account IS who-may-act administration
22
+ * (the same posture as `user`/`role`/`cross_tenant_grant`): the capped root and a scoped tenant
23
+ * admin may administer them; a non-root admin needs the `identity:service_account:*` grant.
24
+ *
25
+ * `system: true` (never `tenantScoped`) — like `user`/`cross_tenant_grant`, the row-level tenant
26
+ * column (`sa_tenant_id`) plus the store's own tenant-scoped SELECTs + the grant-ceiling enforce
27
+ * isolation; we do NOT route it through the RecordEngine's `tenantScoped` create-stamp baseline
28
+ * (its `ServiceAccountStore` writes it directly, stamping `sa_tenant_id` from the admin's session).
29
+ *
30
+ * `readScope: nodeColumn(sa_tenant_id)` (ADR-0027 / ADR-0030) — the account's own row carries its
31
+ * tenant node, so a NON-ROOT admin's list/read/update/delete is walled to rows whose `sa_tenant_id`
32
+ * is IN their reachable subtree (a cross-tenant id is invisible → 404, no existence leak). Root
33
+ * spans all. This is the read/write wall that makes "own tenant only" structural, not handler-level.
34
+ *
35
+ * Action vocabulary is administration, not CRUD data ops — see `@astram/guard`'s
36
+ * `identity-actions.ts` (`read`/`manage` for `service_account`).
37
+ */
38
+ let ServiceAccount = class ServiceAccount {
39
+ id;
40
+ tenantId;
41
+ label;
42
+ status;
43
+ createdAtMs;
44
+ };
45
+ exports.ServiceAccount = ServiceAccount;
46
+ __decorate([
47
+ (0, engine_1.Field)({ column: 'sa_id', type: 'uuid', role: 'primaryKey' }),
48
+ __metadata("design:type", String)
49
+ ], ServiceAccount.prototype, "id", void 0);
50
+ __decorate([
51
+ (0, engine_1.Field)({ column: 'sa_tenant_id', type: 'uuid', required: true, trusted: 'tenantId' }),
52
+ __metadata("design:type", String)
53
+ ], ServiceAccount.prototype, "tenantId", void 0);
54
+ __decorate([
55
+ (0, engine_1.Field)({ column: 'sa_label', type: 'string', required: true }),
56
+ __metadata("design:type", String)
57
+ ], ServiceAccount.prototype, "label", void 0);
58
+ __decorate([
59
+ (0, engine_1.Field)({ column: 'sa_status', type: 'string', role: 'status' }),
60
+ __metadata("design:type", String)
61
+ ], ServiceAccount.prototype, "status", void 0);
62
+ __decorate([
63
+ (0, engine_1.Field)({ column: 'sa_created_at_ms', type: 'number' }),
64
+ __metadata("design:type", Number)
65
+ ], ServiceAccount.prototype, "createdAtMs", void 0);
66
+ exports.ServiceAccount = ServiceAccount = __decorate([
67
+ (0, engine_1.ObjectType)({
68
+ typeKey: 'service_account',
69
+ table: 'service_account',
70
+ schema: schema_1.SCHEMA,
71
+ access: { plane: 'control', system: true },
72
+ readScope: { kind: 'nodeColumn', column: 'sa_tenant_id' },
73
+ }),
74
+ (0, engine_1.PrimaryKey)({ kind: 'uuid', column: 'sa_id' })
75
+ ], ServiceAccount);
@@ -1 +1 @@
1
- {"version":3,"file":"user.object.d.ts","sourceRoot":"","sources":["../src/user.object.ts"],"names":[],"mappings":"AAGA;;;;GAIG;AACH,qBAoBa,IAAI;IAEf,IAAI,EAAG,MAAM,CAAC;IAGd,QAAQ,EAAG,MAAM,CAAC;IAGlB,KAAK,EAAG,MAAM,CAAC;IAGf,MAAM,EAAG,MAAM,CAAC;IAGhB,SAAS,EAAG,MAAM,CAAC;CACpB"}
1
+ {"version":3,"file":"user.object.d.ts","sourceRoot":"","sources":["../src/user.object.ts"],"names":[],"mappings":"AAGA;;;;GAIG;AACH,qBAuBa,IAAI;IAEf,IAAI,EAAG,MAAM,CAAC;IAGd,QAAQ,EAAG,MAAM,CAAC;IAGlB,KAAK,EAAG,MAAM,CAAC;IAGf,MAAM,EAAG,MAAM,CAAC;IAGhB,SAAS,EAAG,MAAM,CAAC;CACpB"}
@@ -59,8 +59,11 @@ exports.User = User = __decorate([
59
59
  // the caller's reachable subtree. A multi-tenant user correctly appears for each subtree
60
60
  // they're a member of. FIXED framework template; the ONLY dynamic values are the `?`-bound
61
61
  // subtree node ids. DECOUPLED from writes. Root spans all.
62
+ // create:'unscoped' (ADR-0034): a NEW user has no membership yet, so the post-insert create
63
+ // wall would deny every create; the membership create that follows IS walled.
62
64
  readScope: {
63
65
  kind: 'subtreeSubquery',
66
+ create: 'unscoped',
64
67
  pkColumn: 'u_uuid',
65
68
  subquerySql: `SELECT cm_user_uuid FROM \`${schema_1.SCHEMA}\`.\`company_membership\` WHERE cm_company_uuid IN (${engine_1.READ_SCOPE_NODES_TOKEN})`,
66
69
  },
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Framework identity migration — `shield_app.service_account`: a MACHINE principal (ADR-0007 /
3
+ * ADR-0033). One row = one service account (an AI agent / MCP server / script identity) that a
4
+ * tenant admin creates, scopes, and revokes. Its HASHED API keys live separately in
5
+ * `shield_authz.service_account_key` (guard migration 1710000000017) — this table is the ACCOUNT,
6
+ * not the credential.
7
+ *
8
+ * Column names for the engine-reflected fields (`sa_id`, `sa_tenant_id`, `sa_label`, `sa_status`,
9
+ * `sa_created_at_ms`) are read from the `@astram/identity` `ServiceAccount` object registration so
10
+ * the DDL and runtime queries share ONE source of truth (rule 30). The additional columns
11
+ * (`sa_scope_subtree`, `sa_role_ids`, `sa_created_by`, `sa_updated_at_ms`) are read+written by the
12
+ * `ServiceAccountStore` directly (not through the RecordEngine), exactly as `users` carries
13
+ * `u_email`/`u_is_platform_superuser` beyond its declared fields.
14
+ *
15
+ * `sa_tenant_id` is server-stamped from the creating admin's session (never client input); a
16
+ * NON-ROOT admin's list/read/update is walled to their subtree by the object's
17
+ * `readScope: nodeColumn(sa_tenant_id)`. Schema `shield_app` is created by earlier migrations.
18
+ * Idempotent (CREATE TABLE IF NOT EXISTS). No hard-coded UUIDs, no seed (accounts are admin-made).
19
+ * `down` drops the table.
20
+ */
21
+ const { ServiceAccount } = require('@astram/identity');
22
+ const { columnForRole, reflectObjectType } = require('@astram/engine');
23
+
24
+ const SCHEMA = 'shield_app';
25
+
26
+ const SaReg = reflectObjectType(ServiceAccount);
27
+ const col = (role) => columnForRole(SaReg, role);
28
+
29
+ async function up({ query }) {
30
+ // Only `primaryKey` + `status` are engine ROLES (single-sourced via col()); the other columns
31
+ // carry no role, so they are named literally — matching their `@Field({ column })` on the object
32
+ // (sa_tenant_id/sa_label/sa_created_at_ms) or are store-only (sa_scope_subtree/sa_role_ids/…).
33
+ await query(`CREATE TABLE IF NOT EXISTS \`${SCHEMA}\`.\`${SaReg.table}\` (
34
+ ${col('primaryKey')} VARCHAR(36) NOT NULL, -- the <accountId> inside the ask_ key
35
+ sa_tenant_id VARCHAR(36) NOT NULL, -- server-stamped; tenant the account is bound to
36
+ sa_label VARCHAR(200) NOT NULL, -- human name shown in the UI (no raw id ever shown)
37
+ sa_scope_subtree JSON NOT NULL, -- reachable node ids (least privilege; ⊆ the creator's subtree)
38
+ sa_role_ids JSON NOT NULL, -- roles the account holds
39
+ ${col('status')} VARCHAR(20) NOT NULL DEFAULT 'active', -- 'active' | 'disabled' (disabled ⇒ keys fail closed)
40
+ sa_created_by VARCHAR(36) NULL, -- admin principal id (attribution; resolved to a name in UI)
41
+ sa_created_at_ms BIGINT NOT NULL, -- epoch-ms
42
+ sa_updated_at_ms BIGINT NULL, -- epoch-ms of last edit
43
+ PRIMARY KEY (${col('primaryKey')}),
44
+ INDEX idx_sa_tenant (sa_tenant_id),
45
+ INDEX idx_sa_status (${col('status')})
46
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`);
47
+ }
48
+
49
+ async function down({ query }) {
50
+ await query(`DROP TABLE IF EXISTS \`${SCHEMA}\`.\`${SaReg.table}\``);
51
+ }
52
+
53
+ module.exports = { up, down };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Framework identity migration — durable re-enroll marker on `shield_identity.session`
3
+ * (security hardening Phase 3 part B review P1, ADR-0035 §3).
4
+ *
5
+ * A recovery-code login binds its session `mustReenroll: true` (restricted to the re-secure
6
+ * allowlist until a fresh MFA device is confirmed) and is never root. That restriction used to
7
+ * live only in Redis, so the refresh-time silent recovery (rebuilding a lost Redis binding from
8
+ * durable state) could not know about it and rebuilt such a session unrestricted (and root for a
9
+ * user on the root allowlist). This column persists it on the durable session row: set at the
10
+ * recovery login, cleared when a fresh device is confirmed, read by the rebuild.
11
+ *
12
+ * Why a column and not the recovery-code table: `mfa_recovery_code.mrc_consumed_at_ms` exists, but
13
+ * regenerating codes (allowed inside a restricted session) DELETEs the principal's rows, which
14
+ * would erase the evidence; and a consumer may supply its own recovery store.
15
+ *
16
+ * Additive + idempotent: `ADD COLUMN IF NOT EXISTS` with a default of 0 (existing rows = not
17
+ * restricted); `down` drops it with `DROP COLUMN IF EXISTS`. Literal schema/column names, no
18
+ * ids, run by the DDL account. `shield_core` already holds DML on `shield_identity` (no grant
19
+ * change).
20
+ */
21
+
22
+ const SCHEMA = 'shield_identity';
23
+
24
+ async function up({ query }) {
25
+ await query(
26
+ `ALTER TABLE \`${SCHEMA}\`.\`session\`
27
+ ADD COLUMN IF NOT EXISTS \`s_must_reenroll\` TINYINT(1) NOT NULL DEFAULT 0`,
28
+ );
29
+ }
30
+
31
+ async function down({ query }) {
32
+ await query(`ALTER TABLE \`${SCHEMA}\`.\`session\` DROP COLUMN IF EXISTS \`s_must_reenroll\``);
33
+ }
34
+
35
+ module.exports = { up, down };