@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,276 @@
|
|
|
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.CrossTenantGrantReadService = void 0;
|
|
13
|
+
// packages/identity/src/features/cross-tenant/cross-tenant-grant-read.service.ts
|
|
14
|
+
/**
|
|
15
|
+
* CrossTenantGrantReadService — the read side of the cross-tenant grant UI,
|
|
16
|
+
* backing the inbox / mine / incoming / offerable-types surfaces the controller
|
|
17
|
+
* exposes. Built over the shared `authzPool` (a `useValue` provider — the mariadb
|
|
18
|
+
* Pool is NOT a Nest provider in this app, matching the non-DI adapter pattern the
|
|
19
|
+
* IdentityFeatureModule already uses for its audit/grant-admin stacks), never
|
|
20
|
+
* through the RecordEngine pipe (`cross_tenant_grant` is a framework-owned table,
|
|
21
|
+
* not a registered object type resolved via the engine).
|
|
22
|
+
*
|
|
23
|
+
* ⛔ TENANT SCOPING (the list-leak class of bug): `listInbox`/`listMine`/
|
|
24
|
+
* `listIncoming` all take `tenantId` from the CALLER (the controller passes
|
|
25
|
+
* `req.principal.tenantId` — never a query/body value) and filter ON it in the SQL
|
|
26
|
+
* WHERE clause. A tenant-A caller can never see tenant-B's grants through these reads,
|
|
27
|
+
* regardless of what a client might try to pass.
|
|
28
|
+
*
|
|
29
|
+
* ⛔ NO RAW UUIDS: every row is resolved via LEFT JOINs to `companies`/`users`/`roles`
|
|
30
|
+
* (COALESCE to a `'Deleted …'` fallback for an orphaned reference) — the uuid columns
|
|
31
|
+
* ride in the row ONLY as API keys (`uuid`, `granteeUuid` are never rendered directly;
|
|
32
|
+
* the UI shows `*Name`/`*Label` fields). `ctg_allowed_actions` is parsed defensively:
|
|
33
|
+
* the mariadb driver auto-parses the JSON column to a JS array in production, but a
|
|
34
|
+
* string is accepted too (the writer/adapter lesson — never a bare `JSON.parse` that
|
|
35
|
+
* assumes one shape).
|
|
36
|
+
*/
|
|
37
|
+
const common_1 = require("@nestjs/common");
|
|
38
|
+
const engine_1 = require("@astram/engine");
|
|
39
|
+
const AUTHZ = 'shield_authz';
|
|
40
|
+
const APP = 'shield_app';
|
|
41
|
+
/**
|
|
42
|
+
* Parse `ctg_allowed_actions` to a `string[]`, accepting BOTH an already-parsed array
|
|
43
|
+
* (production — the mariadb driver auto-parses the JSON column) and a JSON string
|
|
44
|
+
* (test doubles / a raw driver mode). Anything else/malformed -> `[]` (fail-closed,
|
|
45
|
+
* never throws on a malformed cell). Mirrors the writer's `parseCeiling` in
|
|
46
|
+
* `@astram/guard`'s cross-tenant-grant-writer (the ceiling-parse lesson).
|
|
47
|
+
*/
|
|
48
|
+
function parseAllowedActions(raw) {
|
|
49
|
+
if (Array.isArray(raw)) {
|
|
50
|
+
return raw.filter((a) => typeof a === 'string');
|
|
51
|
+
}
|
|
52
|
+
if (typeof raw === 'string' && raw.length > 0) {
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(raw);
|
|
55
|
+
if (Array.isArray(parsed))
|
|
56
|
+
return parsed.filter((a) => typeof a === 'string');
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
/** The grantee's resolved display name: the matching user/role LEFT JOIN, else a fallback. */
|
|
65
|
+
function granteeName(row) {
|
|
66
|
+
const name = row.ctg_grantee_kind === 'role' ? row.grantee_role_name : row.grantee_user_name;
|
|
67
|
+
return name ?? 'Deleted grantee';
|
|
68
|
+
}
|
|
69
|
+
function granteeKind(row) {
|
|
70
|
+
return row.ctg_grantee_kind === 'role' ? 'role' : 'user';
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Epoch-ms comes back from the BIGINT column as a JS `bigint` (the mariadb driver's default
|
|
74
|
+
* for BIGINT), or occasionally a `string`/`number`. Normalize ALL three to a plain `number` —
|
|
75
|
+
* crucially a `bigint` must be coerced, or it reaches `JSON.stringify` (in the HTTP response
|
|
76
|
+
* serializer) and throws `TypeError: Do not know how to serialize a BigInt` (a 500). `Number()`
|
|
77
|
+
* handles bigint | string | number uniformly; epoch-ms is well within `Number.MAX_SAFE_INTEGER`.
|
|
78
|
+
*/
|
|
79
|
+
function expiresAtMs(raw) {
|
|
80
|
+
return Number(raw);
|
|
81
|
+
}
|
|
82
|
+
/** The columns + JOINs shared by every list read (only the WHERE differs per method). */
|
|
83
|
+
const SELECT_COLUMNS = `
|
|
84
|
+
ctg.ctg_uuid,
|
|
85
|
+
ctg.ctg_owning_tenant_uuid,
|
|
86
|
+
owner_co.c_name AS owning_tenant_name,
|
|
87
|
+
ctg.ctg_grantee_tenant_uuid,
|
|
88
|
+
grantee_co.c_name AS grantee_tenant_name,
|
|
89
|
+
ctg.ctg_grantee_kind,
|
|
90
|
+
ctg.ctg_grantee_uuid,
|
|
91
|
+
gu.u_username AS grantee_user_name,
|
|
92
|
+
gr.r_name AS grantee_role_name,
|
|
93
|
+
ctg.ctg_type_key,
|
|
94
|
+
ctg.ctg_scope,
|
|
95
|
+
ctg.ctg_allowed_actions,
|
|
96
|
+
ctg.ctg_status,
|
|
97
|
+
ctg.ctg_offered_by,
|
|
98
|
+
offerer.u_username AS offered_by_name,
|
|
99
|
+
ctg.ctg_expires_at_ms,
|
|
100
|
+
ctg.ctg_created_at
|
|
101
|
+
`;
|
|
102
|
+
const JOINS = `
|
|
103
|
+
FROM \`${AUTHZ}\`.\`cross_tenant_grant\` ctg
|
|
104
|
+
LEFT JOIN \`${APP}\`.\`companies\` owner_co ON owner_co.c_uuid = ctg.ctg_owning_tenant_uuid
|
|
105
|
+
LEFT JOIN \`${APP}\`.\`companies\` grantee_co ON grantee_co.c_uuid = ctg.ctg_grantee_tenant_uuid
|
|
106
|
+
LEFT JOIN \`${APP}\`.\`users\` gu ON ctg.ctg_grantee_kind = 'user' AND gu.u_uuid = ctg.ctg_grantee_uuid
|
|
107
|
+
LEFT JOIN \`${APP}\`.\`roles\` gr ON ctg.ctg_grantee_kind = 'role' AND gr.r_uuid = ctg.ctg_grantee_uuid
|
|
108
|
+
LEFT JOIN \`${APP}\`.\`users\` offerer ON offerer.u_uuid = ctg.ctg_offered_by
|
|
109
|
+
`;
|
|
110
|
+
let CrossTenantGrantReadService = class CrossTenantGrantReadService {
|
|
111
|
+
pool;
|
|
112
|
+
offerableTypeInputs;
|
|
113
|
+
actionsByType;
|
|
114
|
+
/**
|
|
115
|
+
* @param pool the shared resolver pool.
|
|
116
|
+
* @param offerableTypeInputs the ENGINE registrations to filter to `access.tenantScoped
|
|
117
|
+
* === true` types for the offer form's picker (decision 1). Passed in (not imported
|
|
118
|
+
* directly) so this service stays a plain constructor-arg class like its siblings —
|
|
119
|
+
* the module supplies the same merged `[...identityObjects]` set the share-type
|
|
120
|
+
* surface uses, so a future tenantScoped type is picked up the same way it becomes
|
|
121
|
+
* shareable.
|
|
122
|
+
* @param actionsByType per-typeKey action vocabulary for the offerable-types read
|
|
123
|
+
* (decision 1: "the actions per type come from the type's own action set"). Callers
|
|
124
|
+
* pass `actionsFor`, which defaults every type to the CRUD `IDENTITY_ACTIONS` except
|
|
125
|
+
* a type with its own override (e.g. `cross_tenant_grant` -> share/accept/revoke) —
|
|
126
|
+
* the single source every action-vocabulary consumer uses, so a per-type set can
|
|
127
|
+
* never drift between callers.
|
|
128
|
+
*/
|
|
129
|
+
constructor(pool, offerableTypeInputs, actionsByType) {
|
|
130
|
+
this.pool = pool;
|
|
131
|
+
this.offerableTypeInputs = offerableTypeInputs;
|
|
132
|
+
this.actionsByType = actionsByType;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Pending offers sitting in the CALLER's tenant inbox: `ctg_grantee_tenant_uuid =
|
|
136
|
+
* tenantId AND ctg_status = 'offered' AND ctg_expires_at_ms > nowMs` (an expired
|
|
137
|
+
* offer is dead — never surfaced as actionable, mirroring the writer's own active-
|
|
138
|
+
* grant filter). Ordered soonest-expiring first (most urgent to act on).
|
|
139
|
+
*/
|
|
140
|
+
async listInbox(tenantId, nowMs) {
|
|
141
|
+
if (!tenantId)
|
|
142
|
+
return [];
|
|
143
|
+
const rows = (await this.pool.query(`SELECT ${SELECT_COLUMNS}
|
|
144
|
+
${JOINS}
|
|
145
|
+
WHERE ctg.ctg_grantee_tenant_uuid = ?
|
|
146
|
+
AND ctg.ctg_status = 'offered'
|
|
147
|
+
AND ctg.ctg_expires_at_ms > ?
|
|
148
|
+
ORDER BY ctg.ctg_expires_at_ms ASC`, [tenantId, nowMs]));
|
|
149
|
+
return rows.map((row) => ({
|
|
150
|
+
uuid: row.ctg_uuid,
|
|
151
|
+
owningTenantName: row.owning_tenant_name ?? 'Deleted tenant',
|
|
152
|
+
granteeKind: granteeKind(row),
|
|
153
|
+
granteeName: granteeName(row),
|
|
154
|
+
typeKey: row.ctg_type_key,
|
|
155
|
+
typeLabel: row.ctg_type_key,
|
|
156
|
+
scope: row.ctg_scope,
|
|
157
|
+
allowedActions: parseAllowedActions(row.ctg_allowed_actions),
|
|
158
|
+
offeredByName: row.offered_by_name ?? 'Deleted user',
|
|
159
|
+
expiresAtMs: expiresAtMs(row.ctg_expires_at_ms),
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Every grant MY tenant OWNS (`ctg_owning_tenant_uuid = tenantId`), ANY status — the
|
|
164
|
+
* "outgoing" list (what I've offered, whether still pending, accepted, or revoked).
|
|
165
|
+
* Ordered most-recently-offered first (`ctg_created_at DESC`) — the natural order
|
|
166
|
+
* for "what have I done lately" on an owner's own outgoing list.
|
|
167
|
+
*/
|
|
168
|
+
async listMine(tenantId) {
|
|
169
|
+
if (!tenantId)
|
|
170
|
+
return [];
|
|
171
|
+
const rows = (await this.pool.query(`SELECT ${SELECT_COLUMNS}
|
|
172
|
+
${JOINS}
|
|
173
|
+
WHERE ctg.ctg_owning_tenant_uuid = ?
|
|
174
|
+
ORDER BY ctg.ctg_created_at DESC`, [tenantId]));
|
|
175
|
+
return rows.map((row) => ({
|
|
176
|
+
uuid: row.ctg_uuid,
|
|
177
|
+
granteeTenantName: row.grantee_tenant_name ?? 'Deleted tenant',
|
|
178
|
+
granteeKind: granteeKind(row),
|
|
179
|
+
granteeName: granteeName(row),
|
|
180
|
+
typeKey: row.ctg_type_key,
|
|
181
|
+
typeLabel: row.ctg_type_key,
|
|
182
|
+
scope: row.ctg_scope,
|
|
183
|
+
allowedActions: parseAllowedActions(row.ctg_allowed_actions),
|
|
184
|
+
status: row.ctg_status,
|
|
185
|
+
offeredByName: row.offered_by_name ?? 'Deleted user',
|
|
186
|
+
expiresAtMs: expiresAtMs(row.ctg_expires_at_ms),
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Grants ACCEPTED into my tenant (`ctg_grantee_tenant_uuid = tenantId AND ctg_status
|
|
191
|
+
* = 'accepted'`) — the "incoming" (currently-active reach-in) list, distinct from the
|
|
192
|
+
* pending inbox. Includes grants whose expiry has since passed (this reports status as
|
|
193
|
+
* stored — `CrossTenantGrantPort.findActiveGrant` is the ACTUAL enforcement of
|
|
194
|
+
* liveness; this is a read-model list, not a re-implementation of that check), so the
|
|
195
|
+
* UI can show "expired" via `expiresAtMs` if desired. Ordered soonest-expiring first.
|
|
196
|
+
*/
|
|
197
|
+
async listIncoming(tenantId) {
|
|
198
|
+
if (!tenantId)
|
|
199
|
+
return [];
|
|
200
|
+
const rows = (await this.pool.query(`SELECT ${SELECT_COLUMNS}
|
|
201
|
+
${JOINS}
|
|
202
|
+
WHERE ctg.ctg_grantee_tenant_uuid = ?
|
|
203
|
+
AND ctg.ctg_status = 'accepted'
|
|
204
|
+
ORDER BY ctg.ctg_expires_at_ms ASC`, [tenantId]));
|
|
205
|
+
return rows.map((row) => ({
|
|
206
|
+
uuid: row.ctg_uuid,
|
|
207
|
+
owningTenantName: row.owning_tenant_name ?? 'Deleted tenant',
|
|
208
|
+
granteeKind: granteeKind(row),
|
|
209
|
+
granteeName: granteeName(row),
|
|
210
|
+
typeKey: row.ctg_type_key,
|
|
211
|
+
typeLabel: row.ctg_type_key,
|
|
212
|
+
scope: row.ctg_scope,
|
|
213
|
+
allowedActions: parseAllowedActions(row.ctg_allowed_actions),
|
|
214
|
+
offeredByName: row.offered_by_name ?? 'Deleted user',
|
|
215
|
+
expiresAtMs: expiresAtMs(row.ctg_expires_at_ms),
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* The ONE source of truth for "which typeKeys are offerable across tenants" (decision
|
|
220
|
+
* 1: `access.tenantScoped === true`). Reflects the SAME engine registrations the
|
|
221
|
+
* share surface merges (`[...identityObjects]`, passed in by the module) and returns
|
|
222
|
+
* the set of tenantScoped typeKeys. Both the picker read (`listOfferableTypes`) AND the
|
|
223
|
+
* offer-endpoint guard (`isOfferableType`) derive from this — so there is no way for
|
|
224
|
+
* the two to drift (a system type can never be BOTH shown in the picker AND accepted
|
|
225
|
+
* by the offer endpoint, or neither).
|
|
226
|
+
*/
|
|
227
|
+
offerableTypeKeys() {
|
|
228
|
+
const keys = new Set();
|
|
229
|
+
for (const input of this.offerableTypeInputs) {
|
|
230
|
+
const reg = typeof input === 'function' ? (0, engine_1.reflectObjectType)(input) : input;
|
|
231
|
+
if (reg.access?.tenantScoped === true)
|
|
232
|
+
keys.add(reg.typeKey); // decision 1: tenantScoped ONLY
|
|
233
|
+
}
|
|
234
|
+
return keys;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Is `typeKey` offerable across tenants (i.e. a registered tenantScoped type)? The
|
|
238
|
+
* offer controller calls this BEFORE `writer.offer()` so an un-offerable typeKey (any
|
|
239
|
+
* `system` type — e.g. `company`) is rejected with a 400 before any row is written.
|
|
240
|
+
*
|
|
241
|
+
* This closes the H3 defense-in-depth gap: the writer's `assertValidOffer` only checks
|
|
242
|
+
* `typeKey` is a non-empty string, so without this guard a legit own-tenant admin could
|
|
243
|
+
* mint a junk `offered` grant row for a `system` type. It's inert at evaluation TODAY
|
|
244
|
+
* only because no type is both `system` AND `tenantScoped` (system types have no
|
|
245
|
+
* `trusted:'tenantId'`, so the tenant gate never consults a cross-tenant grant for
|
|
246
|
+
* them) — but that safety is a coincidence, not an explicit rule. This makes it
|
|
247
|
+
* explicit, using the SAME tenantScoped filter as the picker (`offerableTypeKeys`).
|
|
248
|
+
*/
|
|
249
|
+
isOfferableType(typeKey) {
|
|
250
|
+
return this.offerableTypeKeys().has(typeKey);
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* The object-type picker source for the offer form (decision 1): the tenantScoped
|
|
254
|
+
* registered types (from `offerableTypeKeys` — the same source `isOfferableType`
|
|
255
|
+
* uses) paired with each type's action vocabulary. No DB query — pure in-memory
|
|
256
|
+
* reflection, wrapped in a resolved promise so the controller's read-surface shape is
|
|
257
|
+
* uniform. The identity types are all `system` and are excluded by construction —
|
|
258
|
+
* only a tenantScoped type (e.g. `shared_resource`) qualifies.
|
|
259
|
+
*/
|
|
260
|
+
async listOfferableTypes() {
|
|
261
|
+
const out = [];
|
|
262
|
+
for (const typeKey of this.offerableTypeKeys()) {
|
|
263
|
+
out.push({
|
|
264
|
+
typeKey,
|
|
265
|
+
label: typeKey, // no separate label registry today — mirrors the module-registry `label: r.typeKey`
|
|
266
|
+
actions: [...this.actionsByType(typeKey)],
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
exports.CrossTenantGrantReadService = CrossTenantGrantReadService;
|
|
273
|
+
exports.CrossTenantGrantReadService = CrossTenantGrantReadService = __decorate([
|
|
274
|
+
(0, common_1.Injectable)(),
|
|
275
|
+
__metadata("design:paramtypes", [Object, Array, Function])
|
|
276
|
+
], CrossTenantGrantReadService);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { CrossTenantGrantWriter } from '@astram/guard';
|
|
2
|
+
import type { Principal } from '@astram/engine';
|
|
3
|
+
import { CrossTenantGrantReadService } from './cross-tenant-grant-read.service.js';
|
|
4
|
+
/** The request shape after the enforcement guard attaches the resolved principal. */
|
|
5
|
+
interface AuthedRequest {
|
|
6
|
+
principal: Principal;
|
|
7
|
+
}
|
|
8
|
+
/** The offer request body — grantee + type + ceiling + expiry ONLY (never authority fields). */
|
|
9
|
+
interface OfferGrantBodyDto {
|
|
10
|
+
granteeTenantId?: string;
|
|
11
|
+
granteeKind?: string;
|
|
12
|
+
granteeUuid?: string;
|
|
13
|
+
typeKey?: string;
|
|
14
|
+
allowedActions?: unknown;
|
|
15
|
+
expiresAtMs?: unknown;
|
|
16
|
+
}
|
|
17
|
+
export declare class CrossTenantGrantController {
|
|
18
|
+
private readonly grantWriter;
|
|
19
|
+
private readonly reads;
|
|
20
|
+
constructor(grantWriter: CrossTenantGrantWriter, reads: CrossTenantGrantReadService);
|
|
21
|
+
/**
|
|
22
|
+
* Offer a cross-tenant grant. `owningTenantId`/`offeredBy` come ONLY from the
|
|
23
|
+
* authenticated principal; the body supplies the grantee + type + ceiling + expiry.
|
|
24
|
+
* `scope` is hard-coded 'object_type' (decision 4) — no recordId accepted in v1.
|
|
25
|
+
*/
|
|
26
|
+
offer(req: AuthedRequest, body: OfferGrantBodyDto): Promise<{
|
|
27
|
+
success: boolean;
|
|
28
|
+
uuid: string;
|
|
29
|
+
}>;
|
|
30
|
+
/** Accept an offered grant. `acceptedBy` comes ONLY from the authenticated principal. */
|
|
31
|
+
accept(req: AuthedRequest, uuid: string): Promise<{
|
|
32
|
+
success: boolean;
|
|
33
|
+
}>;
|
|
34
|
+
/**
|
|
35
|
+
* Revoke a grant. `revokedBy` comes ONLY from the authenticated principal. Either the
|
|
36
|
+
* owning-tenant admin OR a grantee-tenant member may call this (the writer decides) —
|
|
37
|
+
* the grantee side calling this on an `offered` grant IS the "Decline" action; no
|
|
38
|
+
* separate endpoint or status exists for decline.
|
|
39
|
+
*/
|
|
40
|
+
revoke(req: AuthedRequest, uuid: string): Promise<{
|
|
41
|
+
success: boolean;
|
|
42
|
+
}>;
|
|
43
|
+
/**
|
|
44
|
+
* The caller's tenant's pending-offers inbox. Scoped to `req.principal.tenantId` —
|
|
45
|
+
* NEVER a query param — so a caller only ever sees offers made TO their own tenant.
|
|
46
|
+
*/
|
|
47
|
+
inbox(req: AuthedRequest): Promise<{
|
|
48
|
+
rows: import("./cross-tenant-grant-read.service.js").InboxGrantRow[];
|
|
49
|
+
}>;
|
|
50
|
+
/**
|
|
51
|
+
* The caller's tenant's grant book: `mine` (grants my tenant OWNS, any status — the
|
|
52
|
+
* outgoing list) + `incoming` (grants ACCEPTED into my tenant — distinct from the
|
|
53
|
+
* pending inbox). Both scoped to `req.principal.tenantId` server-side.
|
|
54
|
+
*/
|
|
55
|
+
list(req: AuthedRequest): Promise<{
|
|
56
|
+
rows: import("./cross-tenant-grant-read.service.js").MineGrantRow[];
|
|
57
|
+
incoming: import("./cross-tenant-grant-read.service.js").IncomingGrantRow[];
|
|
58
|
+
}>;
|
|
59
|
+
/** The tenantScoped object types (+ their actions) the offer form's picker may show. */
|
|
60
|
+
offerableTypes(): Promise<{
|
|
61
|
+
types: import("./cross-tenant-grant-read.service.js").OfferableTypeRow[];
|
|
62
|
+
}>;
|
|
63
|
+
}
|
|
64
|
+
export {};
|
|
65
|
+
//# sourceMappingURL=cross-tenant-grant.controller.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cross-tenant-grant.controller.d.ts","sourceRoot":"","sources":["../../../src/features/cross-tenant/cross-tenant-grant.controller.ts"],"names":[],"mappings":"AA4CA,OAAO,EAGL,sBAAsB,EAKvB,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAAE,2BAA2B,EAAE,MAAM,sCAAsC,CAAC;AAEnF,qFAAqF;AACrF,UAAU,aAAa;IACrB,SAAS,EAAE,SAAS,CAAC;CACtB;AAED,gGAAgG;AAChG,UAAU,iBAAiB;IACzB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAmDD,qBACa,0BAA0B;IAEO,OAAO,CAAC,QAAQ,CAAC,WAAW;IACtE,OAAO,CAAC,QAAQ,CAAC,KAAK;gBADqC,WAAW,EAAE,sBAAsB,EAC7E,KAAK,EAAE,2BAA2B;IAGrD;;;;OAIG;IAGG,KAAK,CAAQ,GAAG,EAAE,aAAa,EAAU,IAAI,EAAE,iBAAiB;;;;IA+BtE,yFAAyF;IAGnF,MAAM,CAAQ,GAAG,EAAE,aAAa,EAAiB,IAAI,EAAE,MAAM;;;IASnE;;;;;OAKG;IAGG,MAAM,CAAQ,GAAG,EAAE,aAAa,EAAiB,IAAI,EAAE,MAAM;;;IASnE;;;OAGG;IAGG,KAAK,CAAQ,GAAG,EAAE,aAAa;;;IAKrC;;;;OAIG;IAGG,IAAI,CAAQ,GAAG,EAAE,aAAa;;;;IAQpC,wFAAwF;IAGlF,cAAc;;;CAIrB"}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
9
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
|
+
};
|
|
11
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
12
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.CrossTenantGrantController = void 0;
|
|
16
|
+
// packages/identity/src/features/cross-tenant/cross-tenant-grant.controller.ts
|
|
17
|
+
/**
|
|
18
|
+
* CrossTenantGrantController — the HTTP surface over the framework-owned
|
|
19
|
+
* `CrossTenantGrantWriter` (ADR-0009, Phase 4) + `CrossTenantGrantReadService` (Phase
|
|
20
|
+
* C). Mirrors `notes/share.controller.ts`'s shape (principal-derived authority body
|
|
21
|
+
* fields + a `mapGrantError` twin of `mapShareError`).
|
|
22
|
+
*
|
|
23
|
+
* Gated on the dedicated `cross_tenant_grant` object type's own action vocabulary
|
|
24
|
+
* (`share`/`accept`/`revoke` — `features/authz/actions.ts`'s `CROSS_TENANT_GRANT_ACTIONS`),
|
|
25
|
+
* NOT the identity CRUD verbs: `@Classified({ typeKey:'cross_tenant_grant', action:'share'|
|
|
26
|
+
* 'accept'|'revoke' })`. This is the SAME permission key the writer's `authorize` closure
|
|
27
|
+
* checks via `decide()`, so the route guard and the writer agree on exactly one gate — no
|
|
28
|
+
* bespoke admin notion (the deleted `DbGrantAuthorityResolver`/`COMPANY_ADMIN_KEYS`)
|
|
29
|
+
* survives anywhere in this path.
|
|
30
|
+
*
|
|
31
|
+
* ⛔ AUTHORITY FIELDS COME ONLY FROM `req.principal` — NEVER a body field:
|
|
32
|
+
* - offer: owningTenantId = req.principal.tenantId, offeredBy = req.principal.id
|
|
33
|
+
* - accept: acceptedBy = req.principal.id
|
|
34
|
+
* - revoke: revokedBy = req.principal.id
|
|
35
|
+
* The writer independently re-verifies the real authority (via `decide()`) server-side
|
|
36
|
+
* regardless of what the controller passes — but the controller itself must never even
|
|
37
|
+
* READ an authority field from the body, so a malicious body can't influence which
|
|
38
|
+
* tenant/actor is being checked.
|
|
39
|
+
*
|
|
40
|
+
* ⛔ SCOPE IS HARD-CODED 'object_type' IN V1 (decision 4) — the offer body carries no
|
|
41
|
+
* `recordId`/`scope` field; the controller always passes `scope: 'object_type'` to the
|
|
42
|
+
* writer. Record-scoped grants are a writer capability the UI does not expose yet.
|
|
43
|
+
*
|
|
44
|
+
* Decline (grantee side) = the grantee tenant calling `revoke` on an `offered` grant —
|
|
45
|
+
* the writer already permits either side to revoke an offered grant (no new status, no
|
|
46
|
+
* writer change).
|
|
47
|
+
*/
|
|
48
|
+
const common_1 = require("@nestjs/common");
|
|
49
|
+
const guard_1 = require("@astram/guard");
|
|
50
|
+
const cross_tenant_grant_read_service_js_1 = require("./cross-tenant-grant-read.service.js");
|
|
51
|
+
const VALID_GRANTEE_KINDS = ['user', 'role'];
|
|
52
|
+
/** Validate + narrow the offer body, throwing 400 on any missing/malformed field. */
|
|
53
|
+
function parseOfferBody(body) {
|
|
54
|
+
const granteeTenantId = body?.granteeTenantId;
|
|
55
|
+
if (typeof granteeTenantId !== 'string' || granteeTenantId.length === 0) {
|
|
56
|
+
throw new common_1.BadRequestException('granteeTenantId is required');
|
|
57
|
+
}
|
|
58
|
+
const granteeKind = body?.granteeKind;
|
|
59
|
+
if (!VALID_GRANTEE_KINDS.includes(granteeKind)) {
|
|
60
|
+
throw new common_1.BadRequestException("granteeKind must be 'user' or 'role'");
|
|
61
|
+
}
|
|
62
|
+
const granteeUuid = body?.granteeUuid;
|
|
63
|
+
if (typeof granteeUuid !== 'string' || granteeUuid.length === 0) {
|
|
64
|
+
throw new common_1.BadRequestException('granteeUuid is required');
|
|
65
|
+
}
|
|
66
|
+
const typeKey = body?.typeKey;
|
|
67
|
+
if (typeof typeKey !== 'string' || typeKey.length === 0) {
|
|
68
|
+
throw new common_1.BadRequestException('typeKey is required');
|
|
69
|
+
}
|
|
70
|
+
const allowedActions = body?.allowedActions;
|
|
71
|
+
if (!Array.isArray(allowedActions) ||
|
|
72
|
+
allowedActions.length === 0 ||
|
|
73
|
+
!allowedActions.every((a) => typeof a === 'string' && a.length > 0)) {
|
|
74
|
+
throw new common_1.BadRequestException('allowedActions must be a non-empty array of action keys');
|
|
75
|
+
}
|
|
76
|
+
const expiresAtMs = body?.expiresAtMs;
|
|
77
|
+
if (typeof expiresAtMs !== 'number' || !Number.isFinite(expiresAtMs)) {
|
|
78
|
+
throw new common_1.BadRequestException('expiresAtMs is required and must be a finite epoch-ms timestamp');
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
granteeTenantId,
|
|
82
|
+
granteeKind: granteeKind,
|
|
83
|
+
granteeUuid,
|
|
84
|
+
typeKey,
|
|
85
|
+
allowedActions: allowedActions,
|
|
86
|
+
expiresAtMs,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
let CrossTenantGrantController = class CrossTenantGrantController {
|
|
90
|
+
grantWriter;
|
|
91
|
+
reads;
|
|
92
|
+
constructor(grantWriter, reads) {
|
|
93
|
+
this.grantWriter = grantWriter;
|
|
94
|
+
this.reads = reads;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Offer a cross-tenant grant. `owningTenantId`/`offeredBy` come ONLY from the
|
|
98
|
+
* authenticated principal; the body supplies the grantee + type + ceiling + expiry.
|
|
99
|
+
* `scope` is hard-coded 'object_type' (decision 4) — no recordId accepted in v1.
|
|
100
|
+
*/
|
|
101
|
+
async offer(req, body) {
|
|
102
|
+
const parsed = parseOfferBody(body);
|
|
103
|
+
// H3 defense-in-depth: only a tenantScoped ("offerable") type may be granted across
|
|
104
|
+
// tenants. Reject a `system` typeKey (e.g. 'company') with 400 BEFORE the writer runs,
|
|
105
|
+
// so no junk 'offered' row is written. Uses the SAME tenantScoped filter the picker
|
|
106
|
+
// (`listOfferableTypes`) uses — one source of truth, no drift.
|
|
107
|
+
if (!this.reads.isOfferableType(parsed.typeKey)) {
|
|
108
|
+
throw new common_1.BadRequestException(`typeKey '${parsed.typeKey}' is not offerable for cross-tenant grants — ` +
|
|
109
|
+
`only tenant-scoped types can be granted across tenants`);
|
|
110
|
+
}
|
|
111
|
+
let uuid;
|
|
112
|
+
try {
|
|
113
|
+
uuid = await this.grantWriter.offer({
|
|
114
|
+
owningTenantId: req.principal.tenantId,
|
|
115
|
+
granteeTenantId: parsed.granteeTenantId,
|
|
116
|
+
granteeKind: parsed.granteeKind,
|
|
117
|
+
granteeUuid: parsed.granteeUuid,
|
|
118
|
+
typeKey: parsed.typeKey,
|
|
119
|
+
scope: 'object_type',
|
|
120
|
+
allowedActions: parsed.allowedActions,
|
|
121
|
+
expiresAtMs: parsed.expiresAtMs,
|
|
122
|
+
offeredBy: req.principal.id,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
throw mapGrantError(err);
|
|
127
|
+
}
|
|
128
|
+
return { success: true, uuid };
|
|
129
|
+
}
|
|
130
|
+
/** Accept an offered grant. `acceptedBy` comes ONLY from the authenticated principal. */
|
|
131
|
+
async accept(req, uuid) {
|
|
132
|
+
try {
|
|
133
|
+
await this.grantWriter.accept({ grantUuid: uuid, acceptedBy: req.principal.id });
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
throw mapGrantError(err);
|
|
137
|
+
}
|
|
138
|
+
return { success: true };
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Revoke a grant. `revokedBy` comes ONLY from the authenticated principal. Either the
|
|
142
|
+
* owning-tenant admin OR a grantee-tenant member may call this (the writer decides) —
|
|
143
|
+
* the grantee side calling this on an `offered` grant IS the "Decline" action; no
|
|
144
|
+
* separate endpoint or status exists for decline.
|
|
145
|
+
*/
|
|
146
|
+
async revoke(req, uuid) {
|
|
147
|
+
try {
|
|
148
|
+
await this.grantWriter.revoke({ grantUuid: uuid, revokedBy: req.principal.id });
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
throw mapGrantError(err);
|
|
152
|
+
}
|
|
153
|
+
return { success: true };
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* The caller's tenant's pending-offers inbox. Scoped to `req.principal.tenantId` —
|
|
157
|
+
* NEVER a query param — so a caller only ever sees offers made TO their own tenant.
|
|
158
|
+
*/
|
|
159
|
+
async inbox(req) {
|
|
160
|
+
const rows = await this.reads.listInbox(req.principal.tenantId, Date.now());
|
|
161
|
+
return { rows };
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The caller's tenant's grant book: `mine` (grants my tenant OWNS, any status — the
|
|
165
|
+
* outgoing list) + `incoming` (grants ACCEPTED into my tenant — distinct from the
|
|
166
|
+
* pending inbox). Both scoped to `req.principal.tenantId` server-side.
|
|
167
|
+
*/
|
|
168
|
+
async list(req) {
|
|
169
|
+
const [mine, incoming] = await Promise.all([
|
|
170
|
+
this.reads.listMine(req.principal.tenantId),
|
|
171
|
+
this.reads.listIncoming(req.principal.tenantId),
|
|
172
|
+
]);
|
|
173
|
+
return { rows: mine, incoming };
|
|
174
|
+
}
|
|
175
|
+
/** The tenantScoped object types (+ their actions) the offer form's picker may show. */
|
|
176
|
+
async offerableTypes() {
|
|
177
|
+
const types = await this.reads.listOfferableTypes();
|
|
178
|
+
return { types };
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
exports.CrossTenantGrantController = CrossTenantGrantController;
|
|
182
|
+
__decorate([
|
|
183
|
+
(0, common_1.Post)('cross-tenant-grants'),
|
|
184
|
+
(0, guard_1.Classified)({ typeKey: 'cross_tenant_grant', action: 'share' }),
|
|
185
|
+
__param(0, (0, common_1.Req)()),
|
|
186
|
+
__param(1, (0, common_1.Body)()),
|
|
187
|
+
__metadata("design:type", Function),
|
|
188
|
+
__metadata("design:paramtypes", [Object, Object]),
|
|
189
|
+
__metadata("design:returntype", Promise)
|
|
190
|
+
], CrossTenantGrantController.prototype, "offer", null);
|
|
191
|
+
__decorate([
|
|
192
|
+
(0, common_1.Post)('cross-tenant-grants/:uuid/accept'),
|
|
193
|
+
(0, guard_1.Classified)({ typeKey: 'cross_tenant_grant', action: 'accept' }),
|
|
194
|
+
__param(0, (0, common_1.Req)()),
|
|
195
|
+
__param(1, (0, common_1.Param)('uuid')),
|
|
196
|
+
__metadata("design:type", Function),
|
|
197
|
+
__metadata("design:paramtypes", [Object, String]),
|
|
198
|
+
__metadata("design:returntype", Promise)
|
|
199
|
+
], CrossTenantGrantController.prototype, "accept", null);
|
|
200
|
+
__decorate([
|
|
201
|
+
(0, common_1.Post)('cross-tenant-grants/:uuid/revoke'),
|
|
202
|
+
(0, guard_1.Classified)({ typeKey: 'cross_tenant_grant', action: 'revoke' }),
|
|
203
|
+
__param(0, (0, common_1.Req)()),
|
|
204
|
+
__param(1, (0, common_1.Param)('uuid')),
|
|
205
|
+
__metadata("design:type", Function),
|
|
206
|
+
__metadata("design:paramtypes", [Object, String]),
|
|
207
|
+
__metadata("design:returntype", Promise)
|
|
208
|
+
], CrossTenantGrantController.prototype, "revoke", null);
|
|
209
|
+
__decorate([
|
|
210
|
+
(0, common_1.Get)('cross-tenant-grants/inbox'),
|
|
211
|
+
(0, guard_1.Classified)({ typeKey: 'role', action: 'read' }),
|
|
212
|
+
__param(0, (0, common_1.Req)()),
|
|
213
|
+
__metadata("design:type", Function),
|
|
214
|
+
__metadata("design:paramtypes", [Object]),
|
|
215
|
+
__metadata("design:returntype", Promise)
|
|
216
|
+
], CrossTenantGrantController.prototype, "inbox", null);
|
|
217
|
+
__decorate([
|
|
218
|
+
(0, common_1.Get)('cross-tenant-grants'),
|
|
219
|
+
(0, guard_1.Classified)({ typeKey: 'role', action: 'read' }),
|
|
220
|
+
__param(0, (0, common_1.Req)()),
|
|
221
|
+
__metadata("design:type", Function),
|
|
222
|
+
__metadata("design:paramtypes", [Object]),
|
|
223
|
+
__metadata("design:returntype", Promise)
|
|
224
|
+
], CrossTenantGrantController.prototype, "list", null);
|
|
225
|
+
__decorate([
|
|
226
|
+
(0, common_1.Get)('cross-tenant-grants/offerable-types'),
|
|
227
|
+
(0, guard_1.Classified)({ typeKey: 'role', action: 'read' }),
|
|
228
|
+
__metadata("design:type", Function),
|
|
229
|
+
__metadata("design:paramtypes", []),
|
|
230
|
+
__metadata("design:returntype", Promise)
|
|
231
|
+
], CrossTenantGrantController.prototype, "offerableTypes", null);
|
|
232
|
+
exports.CrossTenantGrantController = CrossTenantGrantController = __decorate([
|
|
233
|
+
(0, common_1.Controller)('identity'),
|
|
234
|
+
__param(0, (0, common_1.Inject)(guard_1.SHIELD_CROSS_TENANT_GRANT_WRITER)),
|
|
235
|
+
__metadata("design:paramtypes", [guard_1.CrossTenantGrantWriter,
|
|
236
|
+
cross_tenant_grant_read_service_js_1.CrossTenantGrantReadService])
|
|
237
|
+
], CrossTenantGrantController);
|
|
238
|
+
/**
|
|
239
|
+
* Map the writer's typed errors to HTTP (mirrors `share.controller.ts`'s
|
|
240
|
+
* `mapShareError`): not-permitted -> 403, invalid input -> 400, store outage -> 503.
|
|
241
|
+
*/
|
|
242
|
+
function mapGrantError(err) {
|
|
243
|
+
if (err instanceof guard_1.GrantNotPermittedError)
|
|
244
|
+
return new common_1.ForbiddenException(err.message);
|
|
245
|
+
if (err instanceof guard_1.InvalidGrantError)
|
|
246
|
+
return new common_1.BadRequestException(err.message);
|
|
247
|
+
if (err instanceof guard_1.GrantStoreUnavailableError)
|
|
248
|
+
return new common_1.ServiceUnavailableException(err.message);
|
|
249
|
+
return err;
|
|
250
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `makeAuthorizeCrossTenantGrant` — builds the `GrantAuthorizeFn` guard's
|
|
3
|
+
* `CrossTenantGrantWriter` calls to answer "may this principal perform `action` in
|
|
4
|
+
* `tenantId`?" for the cross-tenant grant lifecycle (offer -> 'share', accept ->
|
|
5
|
+
* 'accept', revoke -> 'revoke').
|
|
6
|
+
*
|
|
7
|
+
* Extracted out of the module wiring (which eagerly opens a real DB pool at forRoot()
|
|
8
|
+
* time, making it unsafe to import from a lightweight unit test) so the closure's
|
|
9
|
+
* LOGIC — build the principal, call `decide()`, return `effect === 'allow'` — is
|
|
10
|
+
* independently testable with fake `readRoleIds`/`decide` collaborators.
|
|
11
|
+
*
|
|
12
|
+
* Cross-tenant sharing is NOT a bespoke authority question; it is the SAME "may this
|
|
13
|
+
* person do this?" question every other action asks, answered by the SAME `decide()`
|
|
14
|
+
* over the `cross_tenant_grant` object type (`plane: 'control'`, actions
|
|
15
|
+
* `share`/`accept`/`revoke`).
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ ROOT CORRECTNESS: `cross_tenant_grant` is `plane:'control'`, so root MAY
|
|
18
|
+
* share/accept/revoke — and the ROUTE guard already ran `decide()` with the caller's
|
|
19
|
+
* REAL principal (incl. its true `isRoot`) before this closure is ever reached. If
|
|
20
|
+
* this closure built its principal with `isRoot: false` unconditionally, a root
|
|
21
|
+
* caller who PASSED the route would then be DENIED here (a route-allows/writer-denies
|
|
22
|
+
* split for the exact same principal). We avoid it by resolving `isRoot` the SAME way
|
|
23
|
+
* the framework's Internal identity adapter does — membership in the boot-loaded
|
|
24
|
+
* `rootIds` allowlist — so this closure's principal is root-correct too.
|
|
25
|
+
*
|
|
26
|
+
* FAIL-CLOSED: `readRoleIds`/`decide` throwing propagates un-caught — the writer's
|
|
27
|
+
* `resolveAuthority` converts a throw to `GrantStoreUnavailableError` (retryable
|
|
28
|
+
* 503). We must NOT catch and return `false`, which would mask an outage as a deny.
|
|
29
|
+
*/
|
|
30
|
+
import type { DecisionAuthority } from '@astram/core';
|
|
31
|
+
import type { GrantAuthorizeFn } from '@astram/guard';
|
|
32
|
+
export interface MakeAuthorizeCrossTenantGrantDeps {
|
|
33
|
+
/** The (possibly second, independently-built) DecisionAuthority to call `decide()` on. */
|
|
34
|
+
readonly authority: Pick<DecisionAuthority, 'decide'>;
|
|
35
|
+
/** The SAME in-effect membership->posting roleIds read every other gate uses. */
|
|
36
|
+
readonly readRoleIds: (principalId: string, tenantId: string) => Promise<string[]>;
|
|
37
|
+
/** True iff `principalId` is in the boot-loaded root allowlist (SAME source `principal.isRoot`
|
|
38
|
+
* was set from on the route's own decide() call). */
|
|
39
|
+
readonly isRoot: (principalId: string) => boolean;
|
|
40
|
+
}
|
|
41
|
+
export declare function makeAuthorizeCrossTenantGrant(deps: MakeAuthorizeCrossTenantGrantDeps): GrantAuthorizeFn;
|
|
42
|
+
//# sourceMappingURL=grant-authorize.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"grant-authorize.d.ts","sourceRoot":"","sources":["../../../src/features/cross-tenant/grant-authorize.ts"],"names":[],"mappings":"AACA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AACtD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAEtD,MAAM,WAAW,iCAAiC;IAChD,0FAA0F;IAC1F,QAAQ,CAAC,SAAS,EAAE,IAAI,CAAC,iBAAiB,EAAE,QAAQ,CAAC,CAAC;IACtD,iFAAiF;IACjF,QAAQ,CAAC,WAAW,EAAE,CAAC,WAAW,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IACnF;0DACsD;IACtD,QAAQ,CAAC,MAAM,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC;CACnD;AAED,wBAAgB,6BAA6B,CAC3C,IAAI,EAAE,iCAAiC,GACtC,gBAAgB,CAkBlB"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.makeAuthorizeCrossTenantGrant = makeAuthorizeCrossTenantGrant;
|
|
4
|
+
function makeAuthorizeCrossTenantGrant(deps) {
|
|
5
|
+
return async (principalId, tenantId, action) => {
|
|
6
|
+
const roleIds = await deps.readRoleIds(principalId, tenantId);
|
|
7
|
+
const principal = {
|
|
8
|
+
kind: 'user',
|
|
9
|
+
id: principalId,
|
|
10
|
+
tenantId,
|
|
11
|
+
roleIds,
|
|
12
|
+
isRoot: deps.isRoot(principalId),
|
|
13
|
+
};
|
|
14
|
+
const decision = await deps.authority.decide(principal, { key: action }, { level: 'object_type', typeKey: 'cross_tenant_grant', tenantId }, { isAuthenticated: true });
|
|
15
|
+
return decision.effect === 'allow';
|
|
16
|
+
};
|
|
17
|
+
}
|