@astram/identity 0.6.0 → 0.7.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/README.md +15 -0
- package/dist/bin/sa-keys-report.d.ts +3 -0
- package/dist/bin/sa-keys-report.d.ts.map +1 -0
- package/dist/bin/sa-keys-report.js +18 -0
- package/dist/features/authz/grant-admin.service.d.ts +22 -5
- package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
- package/dist/features/authz/grant-admin.service.js +45 -10
- package/dist/features/authz/grant-ceiling.d.ts +19 -6
- package/dist/features/authz/grant-ceiling.d.ts.map +1 -1
- package/dist/features/authz/grant-ceiling.js +36 -8
- package/dist/features/authz/record-authorization.d.ts +15 -32
- package/dist/features/authz/record-authorization.d.ts.map +1 -1
- package/dist/features/authz/record-authorization.js +15 -69
- package/dist/features/company/company-hierarchy.service.d.ts +13 -1
- package/dist/features/company/company-hierarchy.service.d.ts.map +1 -1
- package/dist/features/company/company-hierarchy.service.js +36 -9
- package/dist/features/company/company.controller.d.ts +1 -1
- package/dist/features/company/company.controller.d.ts.map +1 -1
- package/dist/features/company/company.controller.js +9 -5
- package/dist/features/company/company.service.d.ts +9 -2
- package/dist/features/company/company.service.d.ts.map +1 -1
- package/dist/features/company/company.service.js +18 -2
- package/dist/features/identity-feature.module.d.ts +15 -8
- package/dist/features/identity-feature.module.d.ts.map +1 -1
- package/dist/features/identity-feature.module.js +75 -28
- package/dist/features/membership/posting.service.d.ts +2 -0
- package/dist/features/membership/posting.service.d.ts.map +1 -1
- package/dist/features/membership/posting.service.js +9 -0
- package/dist/features/role/role.service.d.ts +14 -7
- package/dist/features/role/role.service.d.ts.map +1 -1
- package/dist/features/role/role.service.js +15 -8
- package/dist/features/service-account/lock-service-accounts.d.ts +29 -0
- package/dist/features/service-account/lock-service-accounts.d.ts.map +1 -0
- package/dist/features/service-account/lock-service-accounts.js +35 -0
- package/dist/features/service-account/no-expiry-key-report.d.ts +39 -0
- package/dist/features/service-account/no-expiry-key-report.d.ts.map +1 -0
- package/dist/features/service-account/no-expiry-key-report.js +91 -0
- package/dist/features/shared/http.d.ts +6 -0
- package/dist/features/shared/http.d.ts.map +1 -1
- package/dist/features/shared/http.js +9 -0
- package/dist/features/user/user.service.d.ts +4 -4
- package/dist/features/user/user.service.d.ts.map +1 -1
- package/dist/features/user/user.service.js +9 -6
- package/dist/index.d.ts +1 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -12
- package/dist/internal.d.ts +3 -2
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +3 -2
- package/package.json +7 -4
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Lock the service accounts matching a predicate, without locking the table (Phase 9 #11).
|
|
4
|
+
*
|
|
5
|
+
* `service_account` has no index a role or scope predicate can use (`JSON_CONTAINS` on a JSON
|
|
6
|
+
* column, or an `OR` with one), so `SELECT … WHERE <predicate> FOR UPDATE` scans the whole table
|
|
7
|
+
* and InnoDB (REPEATABLE READ) keeps a next-key lock on every row it scanned: every other agent's
|
|
8
|
+
* edit, and every new agent, waits until the transaction ends. Instead:
|
|
9
|
+
* 1. a NON-locking read finds the candidate ids (a consistent read, no locks);
|
|
10
|
+
* 2. `FOR UPDATE` by primary key locks only those rows, sorted by id (a stable lock order), and
|
|
11
|
+
* re-evaluates the predicate on the latest committed version of each, so a candidate that
|
|
12
|
+
* stopped matching in between is dropped.
|
|
13
|
+
*
|
|
14
|
+
* A row that STARTS matching after step 1 (an agent that gains the role, or the company in its
|
|
15
|
+
* scope) is not locked or returned. Callers accept that (see their docs): the outcome equals the
|
|
16
|
+
* serial order "this transaction, then that edit", which the table-wide lock also allowed (the
|
|
17
|
+
* edit only waited, then applied).
|
|
18
|
+
*
|
|
19
|
+
* `columns` and `where.sql` are fixed SQL written by the caller (never client input); every value
|
|
20
|
+
* is `?`-bound.
|
|
21
|
+
*/
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.lockServiceAccountsWhere = lockServiceAccountsWhere;
|
|
24
|
+
const APP = 'shield_app';
|
|
25
|
+
async function lockServiceAccountsWhere(conn, columns, where) {
|
|
26
|
+
const candidates = (await conn.query(`SELECT sa_id FROM \`${APP}\`.\`service_account\` WHERE ${where.sql}`, [...where.params]));
|
|
27
|
+
const ids = [...new Set(candidates.map((r) => r.sa_id))].sort();
|
|
28
|
+
if (ids.length === 0)
|
|
29
|
+
return [];
|
|
30
|
+
return (await conn.query(`SELECT ${columns}
|
|
31
|
+
FROM \`${APP}\`.\`service_account\`
|
|
32
|
+
WHERE sa_id IN (${ids.map(() => '?').join(', ')}) AND (${where.sql})
|
|
33
|
+
ORDER BY sa_id
|
|
34
|
+
FOR UPDATE`, [...ids, ...where.params]));
|
|
35
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The non-expiring service-account key report (security hardening Phase 8 #13, shipped as the
|
|
3
|
+
* `astram-sa-keys-report` bin in Phase 9 #8).
|
|
4
|
+
*
|
|
5
|
+
* Keys issued before an expiry became mandatory have `sak_expires_at_ms IS NULL` and never expire
|
|
6
|
+
* (a key issued later with an explicit `neverExpires: true` looks the same). This read-only report
|
|
7
|
+
* lists each agent's CURRENT, unrevoked key with no expiry so an operator can rotate it with an
|
|
8
|
+
* expiry. It prints labels and company names only: never a secret, a hash or a raw id.
|
|
9
|
+
*/
|
|
10
|
+
/** Read-only: the current unrevoked key of each agent that has no expiry, with display names. */
|
|
11
|
+
export declare const NO_EXPIRY_KEY_REPORT_SQL = "SELECT sa.sa_label AS label, c.c_name AS company,\n sa.sa_status AS status, k.sak_version AS version, k.sak_created_at_ms AS issuedAtMs\n FROM shield_authz.service_account_key k\n LEFT JOIN shield_app.service_account sa ON sa.sa_id = k.sak_account_id\n LEFT JOIN shield_app.companies c ON c.c_uuid = k.sak_tenant_id\n WHERE k.sak_expires_at_ms IS NULL\n AND k.sak_revoked_at_ms IS NULL\n AND k.sak_version = (SELECT MAX(v.sak_version)\n FROM shield_authz.service_account_key v\n WHERE v.sak_account_id = k.sak_account_id)\n ORDER BY k.sak_created_at_ms";
|
|
12
|
+
/** One non-expiring key, as printed (no ids, no secrets). */
|
|
13
|
+
export interface NonExpiringKey {
|
|
14
|
+
/** The agent's label; `null` when the agent row no longer exists. */
|
|
15
|
+
readonly label: string | null;
|
|
16
|
+
/** The tenant company's name; `null` when the company no longer exists. */
|
|
17
|
+
readonly company: string | null;
|
|
18
|
+
readonly status: string | null;
|
|
19
|
+
readonly version: number;
|
|
20
|
+
readonly issuedAtMs: number | null;
|
|
21
|
+
}
|
|
22
|
+
/** The query surface the report needs (a `mariadb` pool or connection satisfies it). */
|
|
23
|
+
export interface ReportQueryable {
|
|
24
|
+
query(sql: string, params?: unknown[]): Promise<unknown>;
|
|
25
|
+
}
|
|
26
|
+
/** Run the report query and map the rows. Read-only. */
|
|
27
|
+
export declare function findNonExpiringKeys(db: ReportQueryable): Promise<NonExpiringKey[]>;
|
|
28
|
+
/** Human-readable report text (one line per key, plus a summary line). */
|
|
29
|
+
export declare function formatNonExpiringKeyReport(keys: readonly NonExpiringKey[]): string;
|
|
30
|
+
/** The bin's behaviour, testable without a process: returns the exit code. */
|
|
31
|
+
export declare function runNonExpiringKeyReport(opts: {
|
|
32
|
+
readonly argv: readonly string[];
|
|
33
|
+
readonly openDb: () => ReportQueryable & {
|
|
34
|
+
end(): Promise<void>;
|
|
35
|
+
};
|
|
36
|
+
readonly out: (text: string) => void;
|
|
37
|
+
readonly err: (text: string) => void;
|
|
38
|
+
}): Promise<number>;
|
|
39
|
+
//# sourceMappingURL=no-expiry-key-report.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"no-expiry-key-report.d.ts","sourceRoot":"","sources":["../../../src/features/service-account/no-expiry-key-report.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,iGAAiG;AACjG,eAAO,MAAM,wBAAwB,2mBAUP,CAAC;AAE/B,6DAA6D;AAC7D,MAAM,WAAW,cAAc;IAC7B,qEAAqE;IACrE,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,2EAA2E;IAC3E,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CACpC;AAED,wFAAwF;AACxF,MAAM,WAAW,eAAe;IAC9B,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CAC1D;AAMD,wDAAwD;AACxD,wBAAsB,mBAAmB,CAAC,EAAE,EAAE,eAAe,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC,CASxF;AAED,0EAA0E;AAC1E,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,SAAS,cAAc,EAAE,GAAG,MAAM,CAalF;AAcD,8EAA8E;AAC9E,wBAAsB,uBAAuB,CAAC,IAAI,EAAE;IAClD,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,QAAQ,CAAC,MAAM,EAAE,MAAM,eAAe,GAAG;QAAE,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;KAAE,CAAC;IAClE,QAAQ,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,QAAQ,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACtC,GAAG,OAAO,CAAC,MAAM,CAAC,CAwBlB"}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The non-expiring service-account key report (security hardening Phase 8 #13, shipped as the
|
|
4
|
+
* `astram-sa-keys-report` bin in Phase 9 #8).
|
|
5
|
+
*
|
|
6
|
+
* Keys issued before an expiry became mandatory have `sak_expires_at_ms IS NULL` and never expire
|
|
7
|
+
* (a key issued later with an explicit `neverExpires: true` looks the same). This read-only report
|
|
8
|
+
* lists each agent's CURRENT, unrevoked key with no expiry so an operator can rotate it with an
|
|
9
|
+
* expiry. It prints labels and company names only: never a secret, a hash or a raw id.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.NO_EXPIRY_KEY_REPORT_SQL = void 0;
|
|
13
|
+
exports.findNonExpiringKeys = findNonExpiringKeys;
|
|
14
|
+
exports.formatNonExpiringKeyReport = formatNonExpiringKeyReport;
|
|
15
|
+
exports.runNonExpiringKeyReport = runNonExpiringKeyReport;
|
|
16
|
+
/** Read-only: the current unrevoked key of each agent that has no expiry, with display names. */
|
|
17
|
+
exports.NO_EXPIRY_KEY_REPORT_SQL = `SELECT sa.sa_label AS label, c.c_name AS company,
|
|
18
|
+
sa.sa_status AS status, k.sak_version AS version, k.sak_created_at_ms AS issuedAtMs
|
|
19
|
+
FROM shield_authz.service_account_key k
|
|
20
|
+
LEFT JOIN shield_app.service_account sa ON sa.sa_id = k.sak_account_id
|
|
21
|
+
LEFT JOIN shield_app.companies c ON c.c_uuid = k.sak_tenant_id
|
|
22
|
+
WHERE k.sak_expires_at_ms IS NULL
|
|
23
|
+
AND k.sak_revoked_at_ms IS NULL
|
|
24
|
+
AND k.sak_version = (SELECT MAX(v.sak_version)
|
|
25
|
+
FROM shield_authz.service_account_key v
|
|
26
|
+
WHERE v.sak_account_id = k.sak_account_id)
|
|
27
|
+
ORDER BY k.sak_created_at_ms`;
|
|
28
|
+
const str = (v) => (v === null || v === undefined ? null : String(v));
|
|
29
|
+
const num = (v) => v === null || v === undefined || !Number.isFinite(Number(v)) ? null : Number(v);
|
|
30
|
+
/** Run the report query and map the rows. Read-only. */
|
|
31
|
+
async function findNonExpiringKeys(db) {
|
|
32
|
+
const rows = (await db.query(exports.NO_EXPIRY_KEY_REPORT_SQL));
|
|
33
|
+
return rows.map((r) => ({
|
|
34
|
+
label: str(r.label),
|
|
35
|
+
company: str(r.company),
|
|
36
|
+
status: str(r.status),
|
|
37
|
+
version: num(r.version) ?? 0,
|
|
38
|
+
issuedAtMs: num(r.issuedAtMs),
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
/** Human-readable report text (one line per key, plus a summary line). */
|
|
42
|
+
function formatNonExpiringKeyReport(keys) {
|
|
43
|
+
if (keys.length === 0)
|
|
44
|
+
return 'No service-account keys without an expiry.\n';
|
|
45
|
+
const lines = keys.map((k) => {
|
|
46
|
+
const issued = k.issuedAtMs === null ? 'unknown' : new Date(k.issuedAtMs).toISOString();
|
|
47
|
+
return (`- ${k.label ?? '(deleted agent)'} — company: ${k.company ?? '(deleted company)'}` +
|
|
48
|
+
` — status: ${k.status ?? 'unknown'} — key version ${k.version}, issued ${issued}`);
|
|
49
|
+
});
|
|
50
|
+
return (`${keys.length} service-account key(s) with no expiry (rotate each with an expiry):\n` +
|
|
51
|
+
`${lines.join('\n')}\n`);
|
|
52
|
+
}
|
|
53
|
+
const USAGE = `Usage: astram-sa-keys-report [--fail-on-found]
|
|
54
|
+
|
|
55
|
+
Lists service-account (agent) keys that never expire: each agent's current, unrevoked key with no
|
|
56
|
+
expiry. Read-only. Connects with the APP_DB_HOST, APP_DB_PORT, APP_DB_USER, APP_DB_PASSWORD and
|
|
57
|
+
APP_DB_NAME environment variables (the same as the identity module's authz pool).
|
|
58
|
+
|
|
59
|
+
--fail-on-found exit 1 if any key is listed (for CI)
|
|
60
|
+
--help show this text
|
|
61
|
+
|
|
62
|
+
Exit codes: 0 done (or nothing found), 1 found with --fail-on-found, 2 error.
|
|
63
|
+
`;
|
|
64
|
+
/** The bin's behaviour, testable without a process: returns the exit code. */
|
|
65
|
+
async function runNonExpiringKeyReport(opts) {
|
|
66
|
+
const known = new Set(['--fail-on-found', '--help', '-h']);
|
|
67
|
+
const unknown = opts.argv.filter((a) => !known.has(a));
|
|
68
|
+
if (unknown.length > 0) {
|
|
69
|
+
opts.err(`Unknown option: ${unknown.join(' ')}\n\n${USAGE}`);
|
|
70
|
+
return 2;
|
|
71
|
+
}
|
|
72
|
+
if (opts.argv.includes('--help') || opts.argv.includes('-h')) {
|
|
73
|
+
opts.out(USAGE);
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
let db;
|
|
77
|
+
try {
|
|
78
|
+
db = opts.openDb();
|
|
79
|
+
const keys = await findNonExpiringKeys(db);
|
|
80
|
+
opts.out(formatNonExpiringKeyReport(keys));
|
|
81
|
+
return keys.length > 0 && opts.argv.includes('--fail-on-found') ? 1 : 0;
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
// The driver's message names the failing step (host/user at most), never the password.
|
|
85
|
+
opts.err(`astram-sa-keys-report failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
86
|
+
return 2;
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
await db?.end().catch(() => undefined);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -3,6 +3,12 @@ import type { Principal } from '@astram/engine';
|
|
|
3
3
|
export interface AuthedRequest {
|
|
4
4
|
principal: Principal;
|
|
5
5
|
}
|
|
6
|
+
/**
|
|
7
|
+
* Same uuid, compared the way MariaDB does: the uuid columns are `utf8mb4_unicode_ci`, so a route
|
|
8
|
+
* param that differs only in case reaches the same row. Every JS-side id guard (own root, platform
|
|
9
|
+
* administrators) must use this, never `===` (Phase 10 #1).
|
|
10
|
+
*/
|
|
11
|
+
export declare function sameUuid(a: string | null | undefined, b: string | null | undefined): boolean;
|
|
6
12
|
/** Coerce an unknown field value to a string uuid or null. */
|
|
7
13
|
export declare function asUuid(v: unknown): string | null;
|
|
8
14
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../../src/features/shared/http.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAEhD,iFAAiF;AACjF,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,SAAS,CAAC;CACtB;AAED,8DAA8D;AAC9D,wBAAgB,MAAM,CAAC,CAAC,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAEhD;AAED;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,CAAC,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAQnD"}
|
|
1
|
+
{"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../../src/features/shared/http.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAEhD,iFAAiF;AACjF,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,SAAS,CAAC;CACtB;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAAE,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAE5F;AAED,8DAA8D;AAC9D,wBAAgB,MAAM,CAAC,CAAC,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAEhD;AAED;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,CAAC,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAQnD"}
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.sameUuid = sameUuid;
|
|
3
4
|
exports.asUuid = asUuid;
|
|
4
5
|
exports.asEpochMs = asEpochMs;
|
|
6
|
+
/**
|
|
7
|
+
* Same uuid, compared the way MariaDB does: the uuid columns are `utf8mb4_unicode_ci`, so a route
|
|
8
|
+
* param that differs only in case reaches the same row. Every JS-side id guard (own root, platform
|
|
9
|
+
* administrators) must use this, never `===` (Phase 10 #1).
|
|
10
|
+
*/
|
|
11
|
+
function sameUuid(a, b) {
|
|
12
|
+
return typeof a === 'string' && typeof b === 'string' && a.toLowerCase() === b.toLowerCase();
|
|
13
|
+
}
|
|
5
14
|
/** Coerce an unknown field value to a string uuid or null. */
|
|
6
15
|
function asUuid(v) {
|
|
7
16
|
return typeof v === 'string' && v.length > 0 ? v : null;
|
|
@@ -17,9 +17,9 @@ export interface UserPatch {
|
|
|
17
17
|
* Users are GLOBAL (one user can belong to many tenants) and the email is a credential attribute:
|
|
18
18
|
* forgot-password mails the reset link to it. So (security hardening #3, D2):
|
|
19
19
|
* - the patch is whitelisted to `{username, email, status}`;
|
|
20
|
-
* - changing `email` or `status`, and deleting a user, require the
|
|
21
|
-
* (`GrantCeiling.assertDominates`: every membership of the target
|
|
22
|
-
* and every key the target could hold is held by the actor);
|
|
20
|
+
* - changing `username` (Phase 9 #13a), `email` or `status`, and deleting a user, require the
|
|
21
|
+
* actor to DOMINATE the target (`GrantCeiling.assertDominates`: every membership of the target
|
|
22
|
+
* inside the actor's subtree, and every key the target could hold is held by the actor);
|
|
23
23
|
* - an email change burns the target's outstanding password-reset tokens BEFORE the change and
|
|
24
24
|
* notifies the OLD address after it (best-effort), when guard's reset-token store and email
|
|
25
25
|
* sender are wired (`ShieldModule.forRoot({ auth: true })`).
|
|
@@ -46,7 +46,7 @@ export declare class UserService {
|
|
|
46
46
|
listUsers(params?: ListParams): Promise<import("@astram/engine").RecordPage>;
|
|
47
47
|
/** Read a single user. */
|
|
48
48
|
getUser(uuid: string): Promise<import("@astram/engine").RecordView | null>;
|
|
49
|
-
/** Update a user (whitelisted patch; email/status changes need dominance —
|
|
49
|
+
/** Update a user (whitelisted patch; username/email/status changes need dominance — class doc). */
|
|
50
50
|
updateUser(actor: Principal, uuid: string, patch: UserPatch): Promise<void>;
|
|
51
51
|
/**
|
|
52
52
|
* After a status change (security hardening Phase 3): bump every tenant the user is a member of
|
|
@@ -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,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,
|
|
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,mGAAmG;IAC7F,UAAU,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;IAkCjF;;;;;;;;;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"}
|
|
@@ -30,9 +30,9 @@ const USER_SORT_FIELDS = ['username', 'email', 'status', 'createdAt'];
|
|
|
30
30
|
* Users are GLOBAL (one user can belong to many tenants) and the email is a credential attribute:
|
|
31
31
|
* forgot-password mails the reset link to it. So (security hardening #3, D2):
|
|
32
32
|
* - the patch is whitelisted to `{username, email, status}`;
|
|
33
|
-
* - changing `email` or `status`, and deleting a user, require the
|
|
34
|
-
* (`GrantCeiling.assertDominates`: every membership of the target
|
|
35
|
-
* and every key the target could hold is held by the actor);
|
|
33
|
+
* - changing `username` (Phase 9 #13a), `email` or `status`, and deleting a user, require the
|
|
34
|
+
* actor to DOMINATE the target (`GrantCeiling.assertDominates`: every membership of the target
|
|
35
|
+
* inside the actor's subtree, and every key the target could hold is held by the actor);
|
|
36
36
|
* - an email change burns the target's outstanding password-reset tokens BEFORE the change and
|
|
37
37
|
* notifies the OLD address after it (best-effort), when guard's reset-token store and email
|
|
38
38
|
* sender are wired (`ShieldModule.forRoot({ auth: true })`).
|
|
@@ -79,13 +79,16 @@ let UserService = UserService_1 = class UserService {
|
|
|
79
79
|
getUser(uuid) {
|
|
80
80
|
return this.records.get('user', uuid);
|
|
81
81
|
}
|
|
82
|
-
/** Update a user (whitelisted patch; email/status changes need dominance —
|
|
82
|
+
/** Update a user (whitelisted patch; username/email/status changes need dominance — class doc). */
|
|
83
83
|
async updateUser(actor, uuid, patch) {
|
|
84
84
|
const current = await this.records.get('user', uuid);
|
|
85
85
|
if (current === null)
|
|
86
86
|
throw new identity_errors_js_1.IdentityNotFoundError('User not found.');
|
|
87
87
|
const fields = {};
|
|
88
|
-
|
|
88
|
+
// Phase 9 #13a: the username is a sign-in identifier like the email, so changing it needs the
|
|
89
|
+
// same dominance (re-sending the current username changes nothing and needs none).
|
|
90
|
+
const usernameChanged = typeof patch?.username === 'string' && patch.username !== current.fields.username;
|
|
91
|
+
if (usernameChanged)
|
|
89
92
|
fields.username = patch.username;
|
|
90
93
|
const oldEmail = typeof current.fields.email === 'string' ? current.fields.email : '';
|
|
91
94
|
const emailChanged = typeof patch?.email === 'string' && patch.email !== oldEmail;
|
|
@@ -98,7 +101,7 @@ let UserService = UserService_1 = class UserService {
|
|
|
98
101
|
fields.email = patch.email;
|
|
99
102
|
if (statusChanged)
|
|
100
103
|
fields.status = patch.status;
|
|
101
|
-
if (emailChanged || statusChanged || wantsInactive) {
|
|
104
|
+
if (usernameChanged || emailChanged || statusChanged || wantsInactive) {
|
|
102
105
|
await this.ceiling.assertDominates(actor, uuid);
|
|
103
106
|
}
|
|
104
107
|
if (Object.keys(fields).length > 0) {
|
package/dist/index.d.ts
CHANGED
|
@@ -36,19 +36,9 @@ export { identityAuthActionKeys, identityControlPlaneKeys } from './auth-facts-k
|
|
|
36
36
|
* `identityObjects`, and mounts the CRUD controllers — no separate engine setup needed.
|
|
37
37
|
*/
|
|
38
38
|
export { IdentityFeatureModule } from './features/identity-feature.module.js';
|
|
39
|
-
/**
|
|
40
|
-
* The record-tier authorization wiring: the helper that assembles `RecordAuthorizationInputs`
|
|
41
|
-
* for `ShieldModule.forRoot({ recordAuthorization })`. Once wired, `decide()` is real (the
|
|
42
|
-
* framework's record-tier adapters run over the identity app schema) instead of the
|
|
43
|
-
* deny-everything stub. Its pool factory, `createIdentityAuthzPool` (it opens a raw pool), is NOT
|
|
44
|
-
* exported here (Phase 8 #5): import it from `@astram/identity/internal` in the composition root
|
|
45
|
-
* (`src/app.module.ts`).
|
|
46
|
-
*/
|
|
47
|
-
export { buildIdentityRecordAuthorization } from './features/authz/record-authorization.js';
|
|
48
39
|
/**
|
|
49
40
|
* The ADR-0011 per-request session-facts reader (ALS hot path), re-exported so a consumer
|
|
50
|
-
* that wants to construct it directly
|
|
51
|
-
* can do so.
|
|
41
|
+
* that wants to construct it directly can do so.
|
|
52
42
|
*/
|
|
53
43
|
export { AlsSessionFactsReader } from './features/authz/session-facts.reader.js';
|
|
54
44
|
/**
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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,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;
|
|
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;AAU9E;;;GAGG;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.
|
|
3
|
+
exports.DbMembershipChecker = exports.DbScopeSubtreeResolver = exports.AlsSessionFactsReader = 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) +
|
|
@@ -54,20 +54,16 @@ Object.defineProperty(exports, "identityControlPlaneKeys", { enumerable: true, g
|
|
|
54
54
|
*/
|
|
55
55
|
var identity_feature_module_js_1 = require("./features/identity-feature.module.js");
|
|
56
56
|
Object.defineProperty(exports, "IdentityFeatureModule", { enumerable: true, get: function () { return identity_feature_module_js_1.IdentityFeatureModule; } });
|
|
57
|
-
|
|
58
|
-
* The record-tier authorization
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* (`src/app.module.ts`).
|
|
57
|
+
/*
|
|
58
|
+
* The record-tier authorization: since 0.7 (security hardening Phase 9 #7)
|
|
59
|
+
* `IdentityFeatureModule.forRoot({ extraObjects })` builds it and provides it to `ShieldModule`
|
|
60
|
+
* (guard's `SHIELD_RECORD_AUTHORIZATION`); `buildIdentityRecordAuthorization` is removed, and the
|
|
61
|
+
* app passes no `recordAuthorization`. `createIdentityAuthzPool` (a raw pool, e.g. for
|
|
62
|
+
* `DbScopeSubtreeResolver`) stays on `@astram/identity/internal`, composition root only.
|
|
64
63
|
*/
|
|
65
|
-
var record_authorization_js_1 = require("./features/authz/record-authorization.js");
|
|
66
|
-
Object.defineProperty(exports, "buildIdentityRecordAuthorization", { enumerable: true, get: function () { return record_authorization_js_1.buildIdentityRecordAuthorization; } });
|
|
67
64
|
/**
|
|
68
65
|
* The ADR-0011 per-request session-facts reader (ALS hot path), re-exported so a consumer
|
|
69
|
-
* that wants to construct it directly
|
|
70
|
-
* can do so.
|
|
66
|
+
* that wants to construct it directly can do so.
|
|
71
67
|
*/
|
|
72
68
|
var session_facts_reader_js_1 = require("./features/authz/session-facts.reader.js");
|
|
73
69
|
Object.defineProperty(exports, "AlsSessionFactsReader", { enumerable: true, get: function () { return session_facts_reader_js_1.AlsSessionFactsReader; } });
|
package/dist/internal.d.ts
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* @astram/identity/internal — composition-root surface (Phase 8 #5).
|
|
3
3
|
*
|
|
4
4
|
* `createIdentityAuthzPool` opens a raw `mariadb` pool, so it is NOT on the package entry point
|
|
5
|
-
* (`@astram/identity`). An app's `src/app.module.ts` imports it from here to wire
|
|
6
|
-
* `
|
|
5
|
+
* (`@astram/identity`). An app's `src/app.module.ts` imports it from here to wire pool-backed ports
|
|
6
|
+
* such as `new DbScopeSubtreeResolver(authzPool)` (the record authorization itself is built by
|
|
7
|
+
* `IdentityFeatureModule` since 0.7); the scaffolded app's lint wall rejects this
|
|
7
8
|
* subpath everywhere else. Feature code never holds a pool: it uses the RecordEngine.
|
|
8
9
|
*/
|
|
9
10
|
export { createIdentityAuthzPool } from './features/authz/record-authorization.js';
|
package/dist/internal.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"internal.d.ts","sourceRoot":"","sources":["../src/internal.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"internal.d.ts","sourceRoot":"","sources":["../src/internal.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,uBAAuB,EAAE,MAAM,0CAA0C,CAAC"}
|
package/dist/internal.js
CHANGED
|
@@ -5,8 +5,9 @@ exports.createIdentityAuthzPool = void 0;
|
|
|
5
5
|
* @astram/identity/internal — composition-root surface (Phase 8 #5).
|
|
6
6
|
*
|
|
7
7
|
* `createIdentityAuthzPool` opens a raw `mariadb` pool, so it is NOT on the package entry point
|
|
8
|
-
* (`@astram/identity`). An app's `src/app.module.ts` imports it from here to wire
|
|
9
|
-
* `
|
|
8
|
+
* (`@astram/identity`). An app's `src/app.module.ts` imports it from here to wire pool-backed ports
|
|
9
|
+
* such as `new DbScopeSubtreeResolver(authzPool)` (the record authorization itself is built by
|
|
10
|
+
* `IdentityFeatureModule` since 0.7); the scaffolded app's lint wall rejects this
|
|
10
11
|
* subpath everywhere else. Feature code never holds a pool: it uses the RecordEngine.
|
|
11
12
|
*/
|
|
12
13
|
var record_authorization_js_1 = require("./features/authz/record-authorization.js");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astram/identity",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
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",
|
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
]
|
|
24
24
|
}
|
|
25
25
|
},
|
|
26
|
+
"bin": {
|
|
27
|
+
"astram-sa-keys-report": "dist/bin/sa-keys-report.js"
|
|
28
|
+
},
|
|
26
29
|
"files": [
|
|
27
30
|
"dist",
|
|
28
31
|
"migrations"
|
|
@@ -32,10 +35,10 @@
|
|
|
32
35
|
"access": "public"
|
|
33
36
|
},
|
|
34
37
|
"dependencies": {
|
|
35
|
-
"mariadb": "^3.4
|
|
38
|
+
"mariadb": "^3.5.4",
|
|
36
39
|
"@astram/core": "1.4.0",
|
|
37
|
-
"@astram/guard": "0.
|
|
38
|
-
"@astram/engine": "0.5.
|
|
40
|
+
"@astram/guard": "0.10.1",
|
|
41
|
+
"@astram/engine": "0.5.1"
|
|
39
42
|
},
|
|
40
43
|
"peerDependencies": {
|
|
41
44
|
"@nestjs/common": "^11",
|