@astram/identity 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/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 +4 -3
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⛔ DEV/TEST-ONLY coherent identity seed — a no-op in production.
|
|
3
|
+
*
|
|
4
|
+
* Creates ONE id-aligned chain so a fresh app has a real login that resolves end-to-end:
|
|
5
|
+
* company 'Astram Dev Co' (shield_app.companies)
|
|
6
|
+
* user 'admin' (shield_app.users) → the principal
|
|
7
|
+
* role 'Administrator' (shield_app.roles)
|
|
8
|
+
* membership (active) + posting (open window) → links user→company→role
|
|
9
|
+
* credential admin@astram.test / Astram@dev-123 (shield_identity.identity_credential)
|
|
10
|
+
* ic_principal_id = users.u_uuid, ic_tenant_id = companies.c_uuid, ic_roles = [role uuid]
|
|
11
|
+
* grant role → 'identity:*' (shield_authz.role_permission) → lights the admin nav
|
|
12
|
+
*
|
|
13
|
+
* The bug this fixes: the old alice/bob/carol fixtures seeded CREDENTIALS with SYNTHETIC ids
|
|
14
|
+
* (principal-alice/tenant-a) and NO org rows, so /auth/me + auth-facts had nothing to resolve.
|
|
15
|
+
* Here every id is generated once and reused across shield_app + shield_identity + shield_authz.
|
|
16
|
+
*
|
|
17
|
+
* GATE: runs only when NODE_ENV=development OR ASTRAM_SEED_DEV=1. Otherwise a no-op.
|
|
18
|
+
* IDEMPOTENT: every insert is INSERT ... SELECT ... WHERE NOT EXISTS on the BUSINESS key; a
|
|
19
|
+
* second run inserts ZERO rows. No hard-coded UUIDs (UUID() for new PKs; dependents resolved
|
|
20
|
+
* by business key). down() removes the chain by business key.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const APP = 'shield_app';
|
|
24
|
+
const IDENT = 'shield_identity';
|
|
25
|
+
const AUTHZ = 'shield_authz';
|
|
26
|
+
|
|
27
|
+
const COMPANY = 'Astram Dev Co';
|
|
28
|
+
const USERNAME = 'admin';
|
|
29
|
+
const EMAIL = 'admin@astram.test';
|
|
30
|
+
const ROLE = 'Administrator';
|
|
31
|
+
const HINT = 'admin@astram.test';
|
|
32
|
+
// Argon2id encoded hash of 'Astram@dev-123' (dev-only; generated via the framework hasher — Step 1;
|
|
33
|
+
// params match Argon2idPasswordHasher's DEFAULT_PARAMS: memoryCost 19456, timeCost 2, parallelism 1).
|
|
34
|
+
const PASSWORD_HASH = '$argon2id$v=19$m=19456,t=2,p=1$vwqMTNRLVkrwAp4vGlZrYA$U+/nIGzGy+w2KAlMG5epvHqIyjV2hxNaVYDxJcbO6uc';
|
|
35
|
+
const GRANT_KEY = 'identity:*';
|
|
36
|
+
|
|
37
|
+
function seedEnabled() {
|
|
38
|
+
return process.env.NODE_ENV === 'development' || process.env.ASTRAM_SEED_DEV === '1';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function up({ query }) {
|
|
42
|
+
if (!seedEnabled()) return; // production / non-dev → no-op
|
|
43
|
+
|
|
44
|
+
// 1) company (tenant)
|
|
45
|
+
await query(
|
|
46
|
+
`INSERT INTO \`${APP}\`.\`companies\` (c_uuid, c_name, c_status)
|
|
47
|
+
SELECT UUID(), ?, 'active'
|
|
48
|
+
WHERE NOT EXISTS (SELECT 1 FROM \`${APP}\`.\`companies\` WHERE c_name = ?)`,
|
|
49
|
+
[COMPANY, COMPANY],
|
|
50
|
+
);
|
|
51
|
+
// 2) user (principal)
|
|
52
|
+
await query(
|
|
53
|
+
`INSERT INTO \`${APP}\`.\`users\` (u_uuid, u_username, u_email, u_status)
|
|
54
|
+
SELECT UUID(), ?, ?, 'active'
|
|
55
|
+
WHERE NOT EXISTS (SELECT 1 FROM \`${APP}\`.\`users\` WHERE u_username = ?)`,
|
|
56
|
+
[USERNAME, EMAIL, USERNAME],
|
|
57
|
+
);
|
|
58
|
+
// 3) role
|
|
59
|
+
await query(
|
|
60
|
+
`INSERT INTO \`${APP}\`.\`roles\` (r_uuid, r_name)
|
|
61
|
+
SELECT UUID(), ?
|
|
62
|
+
WHERE NOT EXISTS (SELECT 1 FROM \`${APP}\`.\`roles\` WHERE r_name = ?)`,
|
|
63
|
+
[ROLE, ROLE],
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// Resolve the ids we just ensured (by business key — never hard-coded).
|
|
67
|
+
const [company] = await query(`SELECT c_uuid FROM \`${APP}\`.\`companies\` WHERE c_name = ? LIMIT 1`, [COMPANY]);
|
|
68
|
+
const [user] = await query(`SELECT u_uuid FROM \`${APP}\`.\`users\` WHERE u_username = ? LIMIT 1`, [USERNAME]);
|
|
69
|
+
const [role] = await query(`SELECT r_uuid FROM \`${APP}\`.\`roles\` WHERE r_name = ? LIMIT 1`, [ROLE]);
|
|
70
|
+
const companyId = company.c_uuid, userId = user.u_uuid, roleId = role.r_uuid;
|
|
71
|
+
|
|
72
|
+
// 4) membership (active tenant pointer)
|
|
73
|
+
await query(
|
|
74
|
+
`INSERT INTO \`${APP}\`.\`company_membership\` (cm_uuid, cm_user_uuid, cm_company_uuid, cm_is_active, cm_status)
|
|
75
|
+
SELECT UUID(), ?, ?, 1, 'active'
|
|
76
|
+
WHERE NOT EXISTS (SELECT 1 FROM \`${APP}\`.\`company_membership\` WHERE cm_user_uuid = ? AND cm_company_uuid = ?)`,
|
|
77
|
+
[userId, companyId, userId, companyId],
|
|
78
|
+
);
|
|
79
|
+
const [membership] = await query(
|
|
80
|
+
`SELECT cm_uuid FROM \`${APP}\`.\`company_membership\` WHERE cm_user_uuid = ? AND cm_company_uuid = ? LIMIT 1`,
|
|
81
|
+
[userId, companyId],
|
|
82
|
+
);
|
|
83
|
+
const membershipId = membership.cm_uuid;
|
|
84
|
+
|
|
85
|
+
// 5) posting (open window: from/until null → always in-effect)
|
|
86
|
+
await query(
|
|
87
|
+
`INSERT INTO \`${APP}\`.\`membership_posting\` (mp_uuid, mp_membership_uuid, mp_role_uuid, mp_effective_from_ms, mp_effective_until_ms)
|
|
88
|
+
SELECT UUID(), ?, ?, NULL, NULL
|
|
89
|
+
WHERE NOT EXISTS (SELECT 1 FROM \`${APP}\`.\`membership_posting\` WHERE mp_membership_uuid = ? AND mp_role_uuid = ?)`,
|
|
90
|
+
[membershipId, roleId, membershipId, roleId],
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// 6) credential (shield_identity) — ids ALIGNED to the org rows above
|
|
94
|
+
const nowMs = 1_752_192_000_000; // fixed dev timestamp (no Date.now — keeps re-runs identical)
|
|
95
|
+
await query(
|
|
96
|
+
`INSERT INTO \`${IDENT}\`.\`identity_credential\`
|
|
97
|
+
(ic_identity_hint, ic_password_hash, ic_principal_id, ic_tenant_id, ic_roles, ic_created_at_ms, ic_updated_at_ms)
|
|
98
|
+
SELECT ?, ?, ?, ?, ?, ?, ?
|
|
99
|
+
WHERE NOT EXISTS (SELECT 1 FROM \`${IDENT}\`.\`identity_credential\` WHERE ic_identity_hint = ?)`,
|
|
100
|
+
[HINT, PASSWORD_HASH, userId, companyId, JSON.stringify([roleId]), nowMs, nowMs, HINT],
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// 7) grant (shield_authz) — one wildcard row covers identity:company/user/role:read
|
|
104
|
+
await query(
|
|
105
|
+
`INSERT INTO \`${AUTHZ}\`.\`role_permission\` (rp_uuid, rp_role_uuid, rp_perm_key)
|
|
106
|
+
SELECT UUID(), ?, ?
|
|
107
|
+
WHERE NOT EXISTS (SELECT 1 FROM \`${AUTHZ}\`.\`role_permission\` WHERE rp_role_uuid = ? AND rp_perm_key = ?)`,
|
|
108
|
+
[roleId, GRANT_KEY, roleId, GRANT_KEY],
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function down({ query }) {
|
|
113
|
+
// Remove by business key (safe whether or not seed ran). Order: dependents first.
|
|
114
|
+
const [role] = await query(`SELECT r_uuid FROM \`${APP}\`.\`roles\` WHERE r_name = ? LIMIT 1`, [ROLE]);
|
|
115
|
+
if (role) {
|
|
116
|
+
await query(`DELETE FROM \`${AUTHZ}\`.\`role_permission\` WHERE rp_role_uuid = ? AND rp_perm_key = ?`, [role.r_uuid, GRANT_KEY]);
|
|
117
|
+
}
|
|
118
|
+
await query(`DELETE FROM \`${IDENT}\`.\`identity_credential\` WHERE ic_identity_hint = ?`, [HINT]);
|
|
119
|
+
// membership_posting + company_membership cascade on user/company delete; remove user/company/role.
|
|
120
|
+
await query(`DELETE mp FROM \`${APP}\`.\`membership_posting\` mp
|
|
121
|
+
JOIN \`${APP}\`.\`company_membership\` cm ON cm.cm_uuid = mp.mp_membership_uuid
|
|
122
|
+
JOIN \`${APP}\`.\`users\` u ON u.u_uuid = cm.cm_user_uuid WHERE u.u_username = ?`, [USERNAME]);
|
|
123
|
+
await query(`DELETE cm FROM \`${APP}\`.\`company_membership\` cm
|
|
124
|
+
JOIN \`${APP}\`.\`users\` u ON u.u_uuid = cm.cm_user_uuid WHERE u.u_username = ?`, [USERNAME]);
|
|
125
|
+
await query(`DELETE FROM \`${APP}\`.\`users\` WHERE u_username = ?`, [USERNAME]);
|
|
126
|
+
await query(`DELETE FROM \`${APP}\`.\`roles\` WHERE r_name = ?`, [ROLE]);
|
|
127
|
+
await query(`DELETE FROM \`${APP}\`.\`companies\` WHERE c_name = ?`, [COMPANY]);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { up, down };
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⛔ DEV/TEST-ONLY seed — a no-op in production. Extends `...099-SeedDevIdentity.cjs` with a
|
|
3
|
+
* SECOND tenant + a platform root, so Phase 6's cross-tenant offer→accept→revoke flow can be
|
|
4
|
+
* exercised across two REAL tenants out of the box (tenant-a offers, tenant-b accepts).
|
|
5
|
+
*
|
|
6
|
+
* PART A — tenant-b (mirrors ALL 7 steps of `...099`, id-aligned to a second chain):
|
|
7
|
+
* company 'Astram Tenant B' (shield_app.companies)
|
|
8
|
+
* user 'admin-b' (shield_app.users) → the principal
|
|
9
|
+
* role ITS OWN 'Administrator B' role (created by THIS migration — NOT the shared
|
|
10
|
+
* 'Administrator' role `...099` created for tenant-a). Removes the shared-role
|
|
11
|
+
* fragility a Phase-6 review flagged: admin-b's tenant-b authority is now
|
|
12
|
+
* self-evident from its OWN role + membership, not merely a membership join
|
|
13
|
+
* reusing a global role. 'Administrator B' is granted `identity:*` directly
|
|
14
|
+
* (mirrors tenant-a's 'Administrator' → `identity:*` pattern), which covers
|
|
15
|
+
* `identity:cross_tenant_grant:accept` via the same wildcard expansion, so
|
|
16
|
+
* admin-b can ACCEPT a cross-tenant grant in tenant-b out of the box.
|
|
17
|
+
* membership (active) + posting (open window) → links admin-b→company-b→Administrator B
|
|
18
|
+
* credential admin-b@astram.test / Astram@dev-123 (SAME dev hash as `...099`'s admin)
|
|
19
|
+
* ic_principal_id = users.u_uuid, ic_tenant_id = companies.c_uuid, ic_roles = [Administrator B role uuid]
|
|
20
|
+
*
|
|
21
|
+
* PART B — root (mirrors astram3 `1720000000014-DesignateCappedRoot.js`):
|
|
22
|
+
* user 'root', u_is_platform_superuser = 1 (idempotent: INSERT-if-absent, then UPDATE the flag
|
|
23
|
+
* to 1 so re-runs always converge). Root gets NO data-plane wildcard grant — root's authority
|
|
24
|
+
* for control-plane object types (like `cross_tenant_grant`) comes from `isRoot`, not a role
|
|
25
|
+
* grant (mirrors astram3: root operates via the cap, not grants).
|
|
26
|
+
* credential root@astram.test / Astram@dev-123 (SAME dev hash), ic_tenant_id = tenant-a's
|
|
27
|
+
* company id (root still logs into a session scoped to SOME tenant), ic_roles = [] (root's
|
|
28
|
+
* power is the su flag, not a role).
|
|
29
|
+
*
|
|
30
|
+
* ⚠️ RUNTIME NOTE — `rootIds` allowlist (Task 9 / consumer-wiring concern, NOT this migration):
|
|
31
|
+
* The framework resolves `isRoot` at runtime from the `rootIds` list the CONSUMER app passes
|
|
32
|
+
* to `ShieldModule.forRoot({ rootIds })` / `IdentityFeatureModule.forRoot({ rootIds })` — NOT
|
|
33
|
+
* purely from `u_is_platform_superuser` in the DB. Seeding `u_is_platform_superuser = 1` here
|
|
34
|
+
* makes root a superuser IN THE DATABASE, but the consuming app (e.g. astram4) MUST ALSO add
|
|
35
|
+
* this root user's uuid to its `rootIds` config for `isRoot` to be true at request time. This
|
|
36
|
+
* migration CANNOT know that uuid ahead of time (it's `UUID()`-generated) — resolve it via:
|
|
37
|
+
* SELECT u_uuid FROM shield_app.users WHERE u_username = 'root';
|
|
38
|
+
* and wire the result into the consumer's `rootIds` array.
|
|
39
|
+
*
|
|
40
|
+
* GATE: runs only when NODE_ENV=development OR ASTRAM_SEED_DEV=1. Otherwise a no-op.
|
|
41
|
+
* IDEMPOTENT: every insert is INSERT ... SELECT ... WHERE NOT EXISTS on the BUSINESS key; the
|
|
42
|
+
* root flag UPDATE is naturally idempotent (converges to 1 every run). A second run inserts
|
|
43
|
+
* ZERO new rows. No hard-coded UUIDs (UUID() for new PKs; dependents resolved by business key).
|
|
44
|
+
* down() removes both chains by business key (including 'Administrator B' + its identity:*
|
|
45
|
+
* grant) — leaves the shared 'Administrator' role and tenant-a (from `...099`) untouched.
|
|
46
|
+
*
|
|
47
|
+
* Timestamp 1710000000100 sorts strictly after the existing dev seed (...099) and the ctg perm
|
|
48
|
+
* seed (...012) — runs LAST. It creates its own 'Administrator B' role (independent of the
|
|
49
|
+
* shared 'Administrator' role's grants), so it no longer depends on `...012`'s coarse-key
|
|
50
|
+
* expansion for admin-b's accept authority.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
const APP = 'shield_app';
|
|
54
|
+
const IDENT = 'shield_identity';
|
|
55
|
+
const AUTHZ = 'shield_authz';
|
|
56
|
+
|
|
57
|
+
// --- Part A: tenant-b ---
|
|
58
|
+
const COMPANY_B = 'Astram Tenant B';
|
|
59
|
+
const USERNAME_B = 'admin-b';
|
|
60
|
+
const EMAIL_B = 'admin-b@astram.test';
|
|
61
|
+
const ROLE_B = 'Administrator B'; // tenant-b's OWN role (not the shared 'Administrator')
|
|
62
|
+
const HINT_B = 'admin-b@astram.test';
|
|
63
|
+
const IDENTITY_WILDCARD_KEY = 'identity:*';
|
|
64
|
+
|
|
65
|
+
// --- Part B: root ---
|
|
66
|
+
const ROOT_USERNAME = 'root';
|
|
67
|
+
const ROOT_EMAIL = 'root@astram.test';
|
|
68
|
+
const ROOT_HINT = 'root@astram.test';
|
|
69
|
+
|
|
70
|
+
// Argon2id encoded hash of 'Astram@dev-123' (dev-only; SAME hash as ...099's admin — reused
|
|
71
|
+
// verbatim so all dev credentials share one known password; params match
|
|
72
|
+
// Argon2idPasswordHasher's DEFAULT_PARAMS: memoryCost 19456, timeCost 2, parallelism 1).
|
|
73
|
+
const PASSWORD_HASH = '$argon2id$v=19$m=19456,t=2,p=1$vwqMTNRLVkrwAp4vGlZrYA$U+/nIGzGy+w2KAlMG5epvHqIyjV2hxNaVYDxJcbO6uc';
|
|
74
|
+
|
|
75
|
+
function seedEnabled() {
|
|
76
|
+
return process.env.NODE_ENV === 'development' || process.env.ASTRAM_SEED_DEV === '1';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function up({ query }) {
|
|
80
|
+
if (!seedEnabled()) return; // production / non-dev → no-op
|
|
81
|
+
|
|
82
|
+
// ── PART A: tenant-b ────────────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
// A1) company-b (tenant)
|
|
85
|
+
await query(
|
|
86
|
+
`INSERT INTO \`${APP}\`.\`companies\` (c_uuid, c_name, c_status)
|
|
87
|
+
SELECT UUID(), ?, 'active'
|
|
88
|
+
WHERE NOT EXISTS (SELECT 1 FROM \`${APP}\`.\`companies\` WHERE c_name = ?)`,
|
|
89
|
+
[COMPANY_B, COMPANY_B],
|
|
90
|
+
);
|
|
91
|
+
// A2) user admin-b (principal)
|
|
92
|
+
await query(
|
|
93
|
+
`INSERT INTO \`${APP}\`.\`users\` (u_uuid, u_username, u_email, u_status)
|
|
94
|
+
SELECT UUID(), ?, ?, 'active'
|
|
95
|
+
WHERE NOT EXISTS (SELECT 1 FROM \`${APP}\`.\`users\` WHERE u_username = ?)`,
|
|
96
|
+
[USERNAME_B, EMAIL_B, USERNAME_B],
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// Resolve ids by business key (never hard-coded). 'Administrator B' is tenant-b's OWN role
|
|
100
|
+
// (created here, NOT reusing `...099`'s shared 'Administrator') — makes admin-b's isolation
|
|
101
|
+
// self-evident from its own role, not merely from the membership join.
|
|
102
|
+
await query(
|
|
103
|
+
`INSERT INTO \`${APP}\`.\`roles\` (r_uuid, r_name)
|
|
104
|
+
SELECT UUID(), ?
|
|
105
|
+
WHERE NOT EXISTS (SELECT 1 FROM \`${APP}\`.\`roles\` WHERE r_name = ?)`,
|
|
106
|
+
[ROLE_B, ROLE_B],
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const [companyB] = await query(`SELECT c_uuid FROM \`${APP}\`.\`companies\` WHERE c_name = ? LIMIT 1`, [COMPANY_B]);
|
|
110
|
+
const [userB] = await query(`SELECT u_uuid FROM \`${APP}\`.\`users\` WHERE u_username = ? LIMIT 1`, [USERNAME_B]);
|
|
111
|
+
const [roleB] = await query(`SELECT r_uuid FROM \`${APP}\`.\`roles\` WHERE r_name = ? LIMIT 1`, [ROLE_B]);
|
|
112
|
+
const companyBId = companyB.c_uuid, userBId = userB.u_uuid, roleId = roleB.r_uuid;
|
|
113
|
+
|
|
114
|
+
// A3) membership (active tenant pointer, admin-b → company-b)
|
|
115
|
+
await query(
|
|
116
|
+
`INSERT INTO \`${APP}\`.\`company_membership\` (cm_uuid, cm_user_uuid, cm_company_uuid, cm_is_active, cm_status)
|
|
117
|
+
SELECT UUID(), ?, ?, 1, 'active'
|
|
118
|
+
WHERE NOT EXISTS (SELECT 1 FROM \`${APP}\`.\`company_membership\` WHERE cm_user_uuid = ? AND cm_company_uuid = ?)`,
|
|
119
|
+
[userBId, companyBId, userBId, companyBId],
|
|
120
|
+
);
|
|
121
|
+
const [membershipB] = await query(
|
|
122
|
+
`SELECT cm_uuid FROM \`${APP}\`.\`company_membership\` WHERE cm_user_uuid = ? AND cm_company_uuid = ? LIMIT 1`,
|
|
123
|
+
[userBId, companyBId],
|
|
124
|
+
);
|
|
125
|
+
const membershipBId = membershipB.cm_uuid;
|
|
126
|
+
|
|
127
|
+
// A4) posting (open window: from/until null → always in-effect) — admin-b holds Administrator B IN company-b
|
|
128
|
+
await query(
|
|
129
|
+
`INSERT INTO \`${APP}\`.\`membership_posting\` (mp_uuid, mp_membership_uuid, mp_role_uuid, mp_effective_from_ms, mp_effective_until_ms)
|
|
130
|
+
SELECT UUID(), ?, ?, NULL, NULL
|
|
131
|
+
WHERE NOT EXISTS (SELECT 1 FROM \`${APP}\`.\`membership_posting\` WHERE mp_membership_uuid = ? AND mp_role_uuid = ?)`,
|
|
132
|
+
[membershipBId, roleId, membershipBId, roleId],
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// A5) credential (shield_identity) — ids ALIGNED to company-b/admin-b above
|
|
136
|
+
const nowMs = 1_752_192_000_000; // fixed dev timestamp (no Date.now — keeps re-runs identical)
|
|
137
|
+
await query(
|
|
138
|
+
`INSERT INTO \`${IDENT}\`.\`identity_credential\`
|
|
139
|
+
(ic_identity_hint, ic_password_hash, ic_principal_id, ic_tenant_id, ic_roles, ic_created_at_ms, ic_updated_at_ms)
|
|
140
|
+
SELECT ?, ?, ?, ?, ?, ?, ?
|
|
141
|
+
WHERE NOT EXISTS (SELECT 1 FROM \`${IDENT}\`.\`identity_credential\` WHERE ic_identity_hint = ?)`,
|
|
142
|
+
[HINT_B, PASSWORD_HASH, userBId, companyBId, JSON.stringify([roleId]), nowMs, nowMs, HINT_B],
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// A6) grant 'Administrator B' the identity:* wildcard — mirrors tenant-a's 'Administrator'
|
|
146
|
+
// → identity:* pattern (`...099`), so admin-b is a full admin in tenant-b via its OWN role.
|
|
147
|
+
// This wildcard also covers `identity:cross_tenant_grant:accept` (same coarse-key expansion
|
|
148
|
+
// the shared 'Administrator' relied on), so admin-b can accept a cross-tenant grant.
|
|
149
|
+
await query(
|
|
150
|
+
`INSERT INTO \`${AUTHZ}\`.\`role_permission\` (rp_uuid, rp_role_uuid, rp_perm_key)
|
|
151
|
+
SELECT UUID(), ?, ?
|
|
152
|
+
WHERE NOT EXISTS (SELECT 1 FROM \`${AUTHZ}\`.\`role_permission\` WHERE rp_role_uuid = ? AND rp_perm_key = ?)`,
|
|
153
|
+
[roleId, IDENTITY_WILDCARD_KEY, roleId, IDENTITY_WILDCARD_KEY],
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// ── PART B: root ────────────────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
// B1) ensure a dedicated 'root' user exists (su=1). INSERT-if-absent, then UPDATE the flag to
|
|
159
|
+
// 1 so a pre-existing 'root' row (e.g. su=0) always converges on re-run.
|
|
160
|
+
await query(
|
|
161
|
+
`INSERT INTO \`${APP}\`.\`users\` (u_uuid, u_username, u_email, u_status, u_is_platform_superuser)
|
|
162
|
+
SELECT UUID(), ?, ?, 'active', 1
|
|
163
|
+
WHERE NOT EXISTS (SELECT 1 FROM \`${APP}\`.\`users\` WHERE u_username = ?)`,
|
|
164
|
+
[ROOT_USERNAME, ROOT_EMAIL, ROOT_USERNAME],
|
|
165
|
+
);
|
|
166
|
+
await query(`UPDATE \`${APP}\`.\`users\` SET u_is_platform_superuser = 1 WHERE u_username = ?`, [ROOT_USERNAME]);
|
|
167
|
+
|
|
168
|
+
const [rootUser] = await query(`SELECT u_uuid FROM \`${APP}\`.\`users\` WHERE u_username = ? LIMIT 1`, [ROOT_USERNAME]);
|
|
169
|
+
const rootUserId = rootUser.u_uuid;
|
|
170
|
+
|
|
171
|
+
// Root still logs into a session scoped to a tenant — anchor it to tenant-a (the original
|
|
172
|
+
// dev company from ...099), resolved by business key (never hard-coded).
|
|
173
|
+
const [companyA] = await query(`SELECT c_uuid FROM \`${APP}\`.\`companies\` WHERE c_name = ? LIMIT 1`, ['Astram Dev Co']);
|
|
174
|
+
const rootTenantId = companyA ? companyA.c_uuid : companyBId; // fall back to tenant-b if ...099 was skipped
|
|
175
|
+
|
|
176
|
+
// B2) credential (shield_identity) — ic_roles = [] (root's power is the su flag, not a role)
|
|
177
|
+
await query(
|
|
178
|
+
`INSERT INTO \`${IDENT}\`.\`identity_credential\`
|
|
179
|
+
(ic_identity_hint, ic_password_hash, ic_principal_id, ic_tenant_id, ic_roles, ic_created_at_ms, ic_updated_at_ms)
|
|
180
|
+
SELECT ?, ?, ?, ?, ?, ?, ?
|
|
181
|
+
WHERE NOT EXISTS (SELECT 1 FROM \`${IDENT}\`.\`identity_credential\` WHERE ic_identity_hint = ?)`,
|
|
182
|
+
[ROOT_HINT, PASSWORD_HASH, rootUserId, rootTenantId, JSON.stringify([]), nowMs, nowMs, ROOT_HINT],
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function down({ query }) {
|
|
187
|
+
// Reverse PART A (dependents first) — this migration's OWN 'Administrator B' role (+ its
|
|
188
|
+
// identity:* grant) is reversed too; the shared 'Administrator' role + tenant-a (from `...099`)
|
|
189
|
+
// are left untouched.
|
|
190
|
+
const [roleB] = await query(`SELECT r_uuid FROM \`${APP}\`.\`roles\` WHERE r_name = ? LIMIT 1`, [ROLE_B]);
|
|
191
|
+
if (roleB) {
|
|
192
|
+
await query(`DELETE FROM \`${AUTHZ}\`.\`role_permission\` WHERE rp_role_uuid = ? AND rp_perm_key = ?`, [roleB.r_uuid, IDENTITY_WILDCARD_KEY]);
|
|
193
|
+
}
|
|
194
|
+
await query(`DELETE FROM \`${IDENT}\`.\`identity_credential\` WHERE ic_identity_hint = ?`, [HINT_B]);
|
|
195
|
+
await query(`DELETE mp FROM \`${APP}\`.\`membership_posting\` mp
|
|
196
|
+
JOIN \`${APP}\`.\`company_membership\` cm ON cm.cm_uuid = mp.mp_membership_uuid
|
|
197
|
+
JOIN \`${APP}\`.\`users\` u ON u.u_uuid = cm.cm_user_uuid WHERE u.u_username = ?`, [USERNAME_B]);
|
|
198
|
+
await query(`DELETE cm FROM \`${APP}\`.\`company_membership\` cm
|
|
199
|
+
JOIN \`${APP}\`.\`users\` u ON u.u_uuid = cm.cm_user_uuid WHERE u.u_username = ?`, [USERNAME_B]);
|
|
200
|
+
await query(`DELETE FROM \`${APP}\`.\`users\` WHERE u_username = ?`, [USERNAME_B]);
|
|
201
|
+
await query(`DELETE FROM \`${APP}\`.\`companies\` WHERE c_name = ?`, [COMPANY_B]);
|
|
202
|
+
// Role deleted LAST (after the posting that references it, and after its own grant).
|
|
203
|
+
if (roleB) {
|
|
204
|
+
await query(`DELETE FROM \`${APP}\`.\`roles\` WHERE r_uuid = ?`, [roleB.r_uuid]);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Reverse PART B — root's credential + the root user.
|
|
208
|
+
await query(`DELETE FROM \`${IDENT}\`.\`identity_credential\` WHERE ic_identity_hint = ?`, [ROOT_HINT]);
|
|
209
|
+
await query(`DELETE FROM \`${APP}\`.\`users\` WHERE u_username = ?`, [ROOT_USERNAME]);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
module.exports = { up, down };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astram/identity",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Astram framework — the framework-owned identity + tenancy model. Hosts the identity @ObjectType classes (user, company, membership, posting), framework-authored identity migrations, and the identity resolver. Top of the dependency graph: depends on @astram/engine (for @ObjectType/@Field decorators) + @astram/guard / @astram/core (for the auth ports).",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -21,9 +21,10 @@
|
|
|
21
21
|
"access": "public"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
+
"mariadb": "^3.4.0",
|
|
24
25
|
"@astram/core": "1.0.0",
|
|
25
|
-
"@astram/
|
|
26
|
-
"@astram/
|
|
26
|
+
"@astram/engine": "0.1.3",
|
|
27
|
+
"@astram/guard": "0.5.0"
|
|
27
28
|
},
|
|
28
29
|
"peerDependencies": {
|
|
29
30
|
"@nestjs/common": "^11",
|