@things-factory/auth-base 10.1.106 → 10.1.111
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-server/ai-entry/access-assistant.d.ts +50 -0
- package/dist-server/ai-entry/access-assistant.js +145 -0
- package/dist-server/ai-entry/access-assistant.js.map +1 -0
- package/dist-server/ai-entry/access-entry-targets.d.ts +32 -0
- package/dist-server/ai-entry/access-entry-targets.js +197 -0
- package/dist-server/ai-entry/access-entry-targets.js.map +1 -0
- package/dist-server/ai-entry/entry-target-shape.d.ts +82 -0
- package/dist-server/ai-entry/entry-target-shape.js +3 -0
- package/dist-server/ai-entry/entry-target-shape.js.map +1 -0
- package/dist-server/controllers/access-request.d.ts +6 -0
- package/dist-server/controllers/access-request.js +104 -26
- package/dist-server/controllers/access-request.js.map +1 -1
- package/dist-server/index.d.ts +3 -0
- package/dist-server/index.js +3 -0
- package/dist-server/index.js.map +1 -1
- package/dist-server/service/access-request/access-request-query.d.ts +7 -0
- package/dist-server/service/access-request/access-request-query.js +37 -5
- package/dist-server/service/access-request/access-request-query.js.map +1 -1
- package/dist-server/service/index.d.ts +1 -1
- package/dist-server/service/open-fields.js +2 -0
- package/dist-server/service/open-fields.js.map +1 -1
- package/dist-server/service/privilege/privilege-directive.js +2 -2
- package/dist-server/service/privilege/privilege-directive.js.map +1 -1
- package/dist-server/service/privilege/privilege-rejection.d.ts +3 -1
- package/dist-server/service/privilege/privilege-rejection.js +2 -10
- package/dist-server/service/privilege/privilege-rejection.js.map +1 -1
- package/dist-server/service/role/role-query.d.ts +2 -0
- package/dist-server/service/role/role-query.js +12 -0
- package/dist-server/service/role/role-query.js.map +1 -1
- package/dist-server/service/user/index.d.ts +1 -1
- package/dist-server/service/user/user-mutation.js +1 -1
- package/dist-server/service/user/user-mutation.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/dist-server/utils/access-request-review.d.ts +3 -0
- package/dist-server/utils/access-request-review.js +3 -0
- package/dist-server/utils/access-request-review.js.map +1 -1
- package/dist-server/utils/assignable-roles.d.ts +11 -1
- package/dist-server/utils/assignable-roles.js +73 -1
- package/dist-server/utils/assignable-roles.js.map +1 -1
- package/package.json +2 -2
- package/tests/access-assistant-db.test.ts +155 -0
- package/tests/access-entry-targets-db.test.ts +138 -0
- package/tests/access-request-db.test.ts +84 -3
- package/tests/access-request-pg.test.ts +83 -0
- package/tests/access-request-review.test.ts +8 -0
- package/tests/assignable-roles-db.test.ts +79 -1
- package/tests/privilege-directive.test.ts +18 -0
- package/translations/en.json +4 -1
- package/translations/ja.json +4 -1
- package/translations/ko.json +4 -1
- package/translations/ms.json +4 -1
- package/translations/zh.json +4 -1
|
@@ -27,6 +27,9 @@ export interface DecisionFacts {
|
|
|
27
27
|
} | null;
|
|
28
28
|
/** A role id was named but not found in the domain's scope. */
|
|
29
29
|
roleNamedButNotAssignable?: boolean;
|
|
30
|
+
/** The chosen role is the parent domain's - and whether the decider may give it (ADR-0103 decision 8). */
|
|
31
|
+
roleIsParents?: boolean;
|
|
32
|
+
deciderMayChangeParentRoles?: boolean;
|
|
30
33
|
requester: {
|
|
31
34
|
active: boolean;
|
|
32
35
|
inDomain: boolean;
|
|
@@ -83,6 +83,9 @@ function grantIssues(facts, asked) {
|
|
|
83
83
|
issues.push({ code: 'role-does-not-grant', blocking: true, field: 'role', params: { role: role.name, privilege: nameOf(asked) } });
|
|
84
84
|
return issues;
|
|
85
85
|
}
|
|
86
|
+
if (facts.roleIsParents && !facts.deciderMayChangeParentRoles) {
|
|
87
|
+
issues.push({ code: 'role-belongs-to-parent', blocking: true, field: 'role', params: { role: role.name } });
|
|
88
|
+
}
|
|
86
89
|
const more = role.privileges.filter(p => !same(p, asked));
|
|
87
90
|
if (more.length > 0) {
|
|
88
91
|
issues.push({
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"access-request-review.js","sourceRoot":"","sources":["../../server/utils/access-request-review.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * The checks an access decision passes - one function, read by the list screen, by the AI 입력 draft card\n * (its `check`, when the card is made and again when it is pressed) and by `decideAccessRequest` itself\n * (ADR-0103 decision 4: the card and the screen cannot say different things).\n *\n * Pure: the facts are collected by `access-request-facts.ts`; this decides what they mean. A model never\n * judges a role as risky - this does, the same way every time, and the model only words the result.\n *\n * Blocking issues keep the decision from being written. The rest are told to the person deciding.\n */\n\nexport type AccessRequestState = 'PENDING' | 'GRANTED' | 'DENIED' | 'WITHDRAWN' | 'EXPIRED'\n\nexport type AccessDecisionKind = 'GRANT' | 'DENY'\n\nexport type PrivilegeName = { category: string; privilege: string }\n\n/** The privilege that lets its holder put people into roles - a role carrying it hands that power on. */\nexport const ROLE_GRANTING_PRIVILEGE: PrivilegeName = { category: 'user', privilege: 'mutation' }\n\n/** How long a request waits for a decision (questions Q7). */\nexport const ACCESS_REQUEST_PENDING_DAYS = 14\n\nexport interface DecisionFacts {\n request: {\n state: AccessRequestState\n requesterId: string\n category: string\n privilege: string\n expiresAt?: Date | string | null\n } | null\n deciderId: string\n decision: AccessDecisionKind\n reason?: string | null\n /** The role chosen to grant - absent when it is not this domain's to assign. */\n role?: { id: string; name: string; privileges: PrivilegeName[] } | null\n /** A role id was named but not found in the domain's scope. */\n roleNamedButNotAssignable?: boolean\n requester: { active: boolean; inDomain: boolean } | null\n /** The requester holds the privilege now - through some other role, since the request was made. */\n requesterHoldsIt: boolean\n /** Every assignable role that grants the privilege, with how many privileges each carries. */\n candidates: { id: string; name: string; privilegeCount: number }[]\n /** The decider's own privileges - to tell them when a role gives what they do not have. */\n deciderPrivileges: PrivilegeName[]\n /** Whether the decider is a domain owner - an owner may give anything. */\n deciderIsOwner: boolean\n /** The latest earlier denial of the same requester and privilege. */\n deniedBefore?: { by: string; at: Date | string; reason?: string | null } | null\n now: Date\n}\n\nexport interface DecisionIssue {\n code: string\n blocking: boolean\n field?: 'request' | 'decision' | 'role' | 'reason'\n params?: Record<string, string | number | boolean | null>\n}\n\nconst same = (a: PrivilegeName, b: PrivilegeName) => a.category === b.category && a.privilege === b.privilege\nconst nameOf = (p: PrivilegeName) => `${p.category}:${p.privilege}`\n\n/** A pending request past its time is expired, whatever the column still says. */\nexport function effectiveState(state: AccessRequestState, expiresAt: Date | string | null | undefined, now: Date): AccessRequestState {\n if (state !== 'PENDING' || !expiresAt) return state\n const at = new Date(expiresAt)\n return isNaN(at.getTime()) || at.getTime() > now.getTime() ? state : 'EXPIRED'\n}\n\nexport function expiryFrom(now: Date, days = ACCESS_REQUEST_PENDING_DAYS): Date {\n return new Date(now.getTime() + days * 24 * 60 * 60 * 1000)\n}\n\nexport function reviewAccessDecision(facts: DecisionFacts): DecisionIssue[] {\n const issues: DecisionIssue[] = []\n const { request } = facts\n\n if (!request) {\n return [{ code: 'access-request-not-found', blocking: true, field: 'request' }]\n }\n\n const state = effectiveState(request.state, request.expiresAt, facts.now)\n if (state !== 'PENDING') {\n issues.push({ code: 'request-not-pending', blocking: true, field: 'request', params: { state } })\n }\n\n if (request.requesterId === facts.deciderId) {\n issues.push({ code: 'own-request', blocking: true, field: 'request' })\n }\n\n if (!facts.requester || !facts.requester.inDomain || !facts.requester.active) {\n issues.push({ code: 'requester-unavailable', blocking: true, field: 'request' })\n }\n\n if (facts.decision === 'DENY') {\n if (!facts.reason || !facts.reason.trim()) {\n issues.push({ code: 'deny-needs-reason', blocking: true, field: 'reason' })\n }\n } else {\n issues.push(...grantIssues(facts, { category: request.category, privilege: request.privilege }))\n }\n\n if (facts.deniedBefore) {\n issues.push({\n code: 'denied-before',\n blocking: false,\n field: 'request',\n params: { by: facts.deniedBefore.by, at: new Date(facts.deniedBefore.at).toISOString(), reason: facts.deniedBefore.reason ?? '' }\n })\n }\n\n return issues\n}\n\nfunction grantIssues(facts: DecisionFacts, asked: PrivilegeName): DecisionIssue[] {\n const issues: DecisionIssue[] = []\n\n if (facts.requesterHoldsIt) {\n issues.push({ code: 'already-holds', blocking: true, field: 'request', params: { privilege: nameOf(asked) } })\n }\n\n if (!facts.role) {\n issues.push({\n code: facts.roleNamedButNotAssignable ? 'role-not-assignable' : 'role-required',\n blocking: true,\n field: 'role'\n })\n return issues\n }\n\n const { role } = facts\n if (!role.privileges.some(p => same(p, asked))) {\n issues.push({ code: 'role-does-not-grant', blocking: true, field: 'role', params: { role: role.name, privilege: nameOf(asked) } })\n return issues\n }\n\n const more = role.privileges.filter(p => !same(p, asked))\n if (more.length > 0) {\n issues.push({\n code: 'role-grants-more',\n blocking: false,\n field: 'role',\n params: { role: role.name, count: more.length, privileges: more.map(nameOf).join(', ') }\n })\n }\n\n if (!same(asked, ROLE_GRANTING_PRIVILEGE) && role.privileges.some(p => same(p, ROLE_GRANTING_PRIVILEGE))) {\n issues.push({ code: 'role-grants-admin', blocking: false, field: 'role', params: { role: role.name } })\n }\n\n const smaller = facts.candidates\n .filter(c => c.id !== role.id && c.privilegeCount < role.privileges.length)\n .sort((a, b) => a.privilegeCount - b.privilegeCount || a.name.localeCompare(b.name))[0]\n if (smaller) {\n issues.push({ code: 'smaller-role-exists', blocking: false, field: 'role', params: { role: smaller.name, count: smaller.privilegeCount } })\n }\n\n if (!facts.deciderIsOwner) {\n const lacking = role.privileges.filter(p => !facts.deciderPrivileges.some(d => same(d, p)))\n if (lacking.length > 0) {\n /* Told, not refused (questions Q6) - the role screen gives the same role today; ruling both is a separate decision. */\n issues.push({ code: 'decider-lacks-it', blocking: false, field: 'role', params: { privileges: lacking.map(nameOf).join(', ') } })\n }\n }\n\n return issues\n}\n\n/** The smallest candidate - what the draft card proposes by default. Ties go by name, so the answer is the same every time. */\nexport function smallestRole<T extends { name: string; privilegeCount: number }>(candidates: T[]): T | undefined {\n return [...candidates].sort((a, b) => a.privilegeCount - b.privilegeCount || a.name.localeCompare(b.name))[0]\n}\n\nexport const isBlocked = (issues: DecisionIssue[]) => issues.some(issue => issue.blocking)\n"]}
|
|
1
|
+
{"version":3,"file":"access-request-review.js","sourceRoot":"","sources":["../../server/utils/access-request-review.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * The checks an access decision passes - one function, read by the list screen, by the AI 입력 draft card\n * (its `check`, when the card is made and again when it is pressed) and by `decideAccessRequest` itself\n * (ADR-0103 decision 4: the card and the screen cannot say different things).\n *\n * Pure: the facts are collected by `access-request-facts.ts`; this decides what they mean. A model never\n * judges a role as risky - this does, the same way every time, and the model only words the result.\n *\n * Blocking issues keep the decision from being written. The rest are told to the person deciding.\n */\n\nexport type AccessRequestState = 'PENDING' | 'GRANTED' | 'DENIED' | 'WITHDRAWN' | 'EXPIRED'\n\nexport type AccessDecisionKind = 'GRANT' | 'DENY'\n\nexport type PrivilegeName = { category: string; privilege: string }\n\n/** The privilege that lets its holder put people into roles - a role carrying it hands that power on. */\nexport const ROLE_GRANTING_PRIVILEGE: PrivilegeName = { category: 'user', privilege: 'mutation' }\n\n/** How long a request waits for a decision (questions Q7). */\nexport const ACCESS_REQUEST_PENDING_DAYS = 14\n\nexport interface DecisionFacts {\n request: {\n state: AccessRequestState\n requesterId: string\n category: string\n privilege: string\n expiresAt?: Date | string | null\n } | null\n deciderId: string\n decision: AccessDecisionKind\n reason?: string | null\n /** The role chosen to grant - absent when it is not this domain's to assign. */\n role?: { id: string; name: string; privileges: PrivilegeName[] } | null\n /** A role id was named but not found in the domain's scope. */\n roleNamedButNotAssignable?: boolean\n /** The chosen role is the parent domain's - and whether the decider may give it (ADR-0103 decision 8). */\n roleIsParents?: boolean\n deciderMayChangeParentRoles?: boolean\n requester: { active: boolean; inDomain: boolean } | null\n /** The requester holds the privilege now - through some other role, since the request was made. */\n requesterHoldsIt: boolean\n /** Every assignable role that grants the privilege, with how many privileges each carries. */\n candidates: { id: string; name: string; privilegeCount: number }[]\n /** The decider's own privileges - to tell them when a role gives what they do not have. */\n deciderPrivileges: PrivilegeName[]\n /** Whether the decider is a domain owner - an owner may give anything. */\n deciderIsOwner: boolean\n /** The latest earlier denial of the same requester and privilege. */\n deniedBefore?: { by: string; at: Date | string; reason?: string | null } | null\n now: Date\n}\n\nexport interface DecisionIssue {\n code: string\n blocking: boolean\n field?: 'request' | 'decision' | 'role' | 'reason'\n params?: Record<string, string | number | boolean | null>\n}\n\nconst same = (a: PrivilegeName, b: PrivilegeName) => a.category === b.category && a.privilege === b.privilege\nconst nameOf = (p: PrivilegeName) => `${p.category}:${p.privilege}`\n\n/** A pending request past its time is expired, whatever the column still says. */\nexport function effectiveState(state: AccessRequestState, expiresAt: Date | string | null | undefined, now: Date): AccessRequestState {\n if (state !== 'PENDING' || !expiresAt) return state\n const at = new Date(expiresAt)\n return isNaN(at.getTime()) || at.getTime() > now.getTime() ? state : 'EXPIRED'\n}\n\nexport function expiryFrom(now: Date, days = ACCESS_REQUEST_PENDING_DAYS): Date {\n return new Date(now.getTime() + days * 24 * 60 * 60 * 1000)\n}\n\nexport function reviewAccessDecision(facts: DecisionFacts): DecisionIssue[] {\n const issues: DecisionIssue[] = []\n const { request } = facts\n\n if (!request) {\n return [{ code: 'access-request-not-found', blocking: true, field: 'request' }]\n }\n\n const state = effectiveState(request.state, request.expiresAt, facts.now)\n if (state !== 'PENDING') {\n issues.push({ code: 'request-not-pending', blocking: true, field: 'request', params: { state } })\n }\n\n if (request.requesterId === facts.deciderId) {\n issues.push({ code: 'own-request', blocking: true, field: 'request' })\n }\n\n if (!facts.requester || !facts.requester.inDomain || !facts.requester.active) {\n issues.push({ code: 'requester-unavailable', blocking: true, field: 'request' })\n }\n\n if (facts.decision === 'DENY') {\n if (!facts.reason || !facts.reason.trim()) {\n issues.push({ code: 'deny-needs-reason', blocking: true, field: 'reason' })\n }\n } else {\n issues.push(...grantIssues(facts, { category: request.category, privilege: request.privilege }))\n }\n\n if (facts.deniedBefore) {\n issues.push({\n code: 'denied-before',\n blocking: false,\n field: 'request',\n params: { by: facts.deniedBefore.by, at: new Date(facts.deniedBefore.at).toISOString(), reason: facts.deniedBefore.reason ?? '' }\n })\n }\n\n return issues\n}\n\nfunction grantIssues(facts: DecisionFacts, asked: PrivilegeName): DecisionIssue[] {\n const issues: DecisionIssue[] = []\n\n if (facts.requesterHoldsIt) {\n issues.push({ code: 'already-holds', blocking: true, field: 'request', params: { privilege: nameOf(asked) } })\n }\n\n if (!facts.role) {\n issues.push({\n code: facts.roleNamedButNotAssignable ? 'role-not-assignable' : 'role-required',\n blocking: true,\n field: 'role'\n })\n return issues\n }\n\n const { role } = facts\n if (!role.privileges.some(p => same(p, asked))) {\n issues.push({ code: 'role-does-not-grant', blocking: true, field: 'role', params: { role: role.name, privilege: nameOf(asked) } })\n return issues\n }\n\n if (facts.roleIsParents && !facts.deciderMayChangeParentRoles) {\n issues.push({ code: 'role-belongs-to-parent', blocking: true, field: 'role', params: { role: role.name } })\n }\n\n const more = role.privileges.filter(p => !same(p, asked))\n if (more.length > 0) {\n issues.push({\n code: 'role-grants-more',\n blocking: false,\n field: 'role',\n params: { role: role.name, count: more.length, privileges: more.map(nameOf).join(', ') }\n })\n }\n\n if (!same(asked, ROLE_GRANTING_PRIVILEGE) && role.privileges.some(p => same(p, ROLE_GRANTING_PRIVILEGE))) {\n issues.push({ code: 'role-grants-admin', blocking: false, field: 'role', params: { role: role.name } })\n }\n\n const smaller = facts.candidates\n .filter(c => c.id !== role.id && c.privilegeCount < role.privileges.length)\n .sort((a, b) => a.privilegeCount - b.privilegeCount || a.name.localeCompare(b.name))[0]\n if (smaller) {\n issues.push({ code: 'smaller-role-exists', blocking: false, field: 'role', params: { role: smaller.name, count: smaller.privilegeCount } })\n }\n\n if (!facts.deciderIsOwner) {\n const lacking = role.privileges.filter(p => !facts.deciderPrivileges.some(d => same(d, p)))\n if (lacking.length > 0) {\n /* Told, not refused (questions Q6) - the role screen gives the same role today; ruling both is a separate decision. */\n issues.push({ code: 'decider-lacks-it', blocking: false, field: 'role', params: { privileges: lacking.map(nameOf).join(', ') } })\n }\n }\n\n return issues\n}\n\n/** The smallest candidate - what the draft card proposes by default. Ties go by name, so the answer is the same every time. */\nexport function smallestRole<T extends { name: string; privilegeCount: number }>(candidates: T[]): T | undefined {\n return [...candidates].sort((a, b) => a.privilegeCount - b.privilegeCount || a.name.localeCompare(b.name))[0]\n}\n\nexport const isBlocked = (issues: DecisionIssue[]) => issues.some(issue => issue.blocking)\n"]}
|
|
@@ -6,6 +6,8 @@ import { User } from '../service/user/user.js';
|
|
|
6
6
|
export declare function whereAssignableBy<T extends WhereExpressionBuilder>(qb: T, alias: string, domain: Domain): T;
|
|
7
7
|
/** The roles among `ids` that `domain` can assign. */
|
|
8
8
|
export declare function findAssignableRoles(ids: string[], domain: Domain, tx?: EntityManager): Promise<Role[]>;
|
|
9
|
+
/** The roles `domain` can assign whose name is exactly `name`, with their privileges. */
|
|
10
|
+
export declare function findAssignableRolesNamed(name: string, domain: Domain, tx?: EntityManager): Promise<Role[]>;
|
|
9
11
|
/** Refuses with the ids that are not this domain's to assign - a name and values, spoken by `SpeakRefusals`. */
|
|
10
12
|
export declare function requireAssignableRoles(ids: string[], domain: Domain, tx?: EntityManager): Promise<Role[]>;
|
|
11
13
|
/**
|
|
@@ -14,10 +16,18 @@ export declare function requireAssignableRoles(ids: string[], domain: Domain, tx
|
|
|
14
16
|
*
|
|
15
17
|
* @returns the saved user, and the roles that were added (not already held).
|
|
16
18
|
*/
|
|
19
|
+
/**
|
|
20
|
+
* May `actor` change who holds the parent domain's roles? Only someone who could decide in the parent too: its owner,
|
|
21
|
+
* or a holder of the role-giving privilege there (ADR-0103 decision 8).
|
|
22
|
+
*
|
|
23
|
+
* A role's holders are not tied to a domain - a parent's role given from a child counts wherever the person is a
|
|
24
|
+
* member: in the parent, and in the parent's other children. So the child's own deciders cannot be enough.
|
|
25
|
+
*/
|
|
26
|
+
export declare function mayChangeParentRoles(actor: User | undefined, domain: Domain, tx?: EntityManager): Promise<boolean>;
|
|
17
27
|
export declare function assignRoles(user: User, { add, remove }: {
|
|
18
28
|
add?: string[];
|
|
19
29
|
remove?: string[];
|
|
20
|
-
}, domain: Domain, tx?: EntityManager): Promise<{
|
|
30
|
+
}, domain: Domain, tx?: EntityManager, actor?: User): Promise<{
|
|
21
31
|
user: User;
|
|
22
32
|
added: Role[];
|
|
23
33
|
}>;
|
|
@@ -1,8 +1,43 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
36
|
exports.whereAssignableBy = whereAssignableBy;
|
|
4
37
|
exports.findAssignableRoles = findAssignableRoles;
|
|
38
|
+
exports.findAssignableRolesNamed = findAssignableRolesNamed;
|
|
5
39
|
exports.requireAssignableRoles = requireAssignableRoles;
|
|
40
|
+
exports.mayChangeParentRoles = mayChangeParentRoles;
|
|
6
41
|
exports.assignRoles = assignRoles;
|
|
7
42
|
const typeorm_1 = require("typeorm");
|
|
8
43
|
const shell_1 = require("@things-factory/shell");
|
|
@@ -53,6 +88,14 @@ async function findAssignableRoles(ids, domain, tx) {
|
|
|
53
88
|
const qb = (0, shell_1.getRepository)(role_js_1.Role, tx).createQueryBuilder('ROLE').where({ id: (0, typeorm_1.In)(unique) });
|
|
54
89
|
return await whereAssignableBy(qb, 'ROLE', domain).getMany();
|
|
55
90
|
}
|
|
91
|
+
/** The roles `domain` can assign whose name is exactly `name`, with their privileges. */
|
|
92
|
+
async function findAssignableRolesNamed(name, domain, tx) {
|
|
93
|
+
const said = (name || '').trim();
|
|
94
|
+
if (!said)
|
|
95
|
+
return [];
|
|
96
|
+
const qb = (0, shell_1.getRepository)(role_js_1.Role, tx).createQueryBuilder('ROLE').leftJoinAndSelect('ROLE.privileges', 'ROLE_PRIVILEGE').where('ROLE.name = :said', { said });
|
|
97
|
+
return await whereAssignableBy(qb, 'ROLE', domain).getMany();
|
|
98
|
+
}
|
|
56
99
|
/** Refuses with the ids that are not this domain's to assign - a name and values, spoken by `SpeakRefusals`. */
|
|
57
100
|
async function requireAssignableRoles(ids, domain, tx) {
|
|
58
101
|
const found = await findAssignableRoles(ids, domain, tx);
|
|
@@ -69,7 +112,25 @@ async function requireAssignableRoles(ids, domain, tx) {
|
|
|
69
112
|
*
|
|
70
113
|
* @returns the saved user, and the roles that were added (not already held).
|
|
71
114
|
*/
|
|
72
|
-
|
|
115
|
+
/**
|
|
116
|
+
* May `actor` change who holds the parent domain's roles? Only someone who could decide in the parent too: its owner,
|
|
117
|
+
* or a holder of the role-giving privilege there (ADR-0103 decision 8).
|
|
118
|
+
*
|
|
119
|
+
* A role's holders are not tied to a domain - a parent's role given from a child counts wherever the person is a
|
|
120
|
+
* member: in the parent, and in the parent's other children. So the child's own deciders cannot be enough.
|
|
121
|
+
*/
|
|
122
|
+
async function mayChangeParentRoles(actor, domain, tx) {
|
|
123
|
+
if (!actor?.id || !domain?.parentId)
|
|
124
|
+
return false;
|
|
125
|
+
const parent = await (0, shell_1.getRepository)(shell_1.Domain, tx).findOneBy({ id: domain.parentId });
|
|
126
|
+
if (!parent)
|
|
127
|
+
return false;
|
|
128
|
+
const { isDomainOwner } = await Promise.resolve().then(() => __importStar(require('./domain-ownership.js')));
|
|
129
|
+
if (await isDomainOwner(parent, actor, tx))
|
|
130
|
+
return true;
|
|
131
|
+
return await user_js_1.User.hasPrivilege('mutation', 'user', parent, actor);
|
|
132
|
+
}
|
|
133
|
+
async function assignRoles(user, { add = [], remove = [] }, domain, tx, actor) {
|
|
73
134
|
const adding = await requireAssignableRoles(add, domain, tx);
|
|
74
135
|
/*
|
|
75
136
|
* `remove` is the caller's scope ("every role this screen shows"), not a demand - `user-detail.ts` builds it
|
|
@@ -82,6 +143,17 @@ async function assignRoles(user, { add = [], remove = [] }, domain, tx) {
|
|
|
82
143
|
const held = (user.roles || []).filter(role => !removingIds.has(role.id));
|
|
83
144
|
const heldIds = new Set(held.map(role => role.id));
|
|
84
145
|
const added = adding.filter(role => !heldIds.has(role.id));
|
|
146
|
+
/*
|
|
147
|
+
* Only what actually changes is asked: a screen sends every role it shows as `remove` and the kept ones again as `add`,
|
|
148
|
+
* so a parent role left as it was is no change. Granted roles are not the parent's - their domain delegated them
|
|
149
|
+
* to this one through grantRoles and takes them back the same way, so assigning them here is the contract.
|
|
150
|
+
*/
|
|
151
|
+
const removed = (user.roles || []).filter(role => removingIds.has(role.id));
|
|
152
|
+
const parentChanges = [...added, ...removed].filter(role => domain.parentId && role.domainId === domain.parentId);
|
|
153
|
+
if (parentChanges.length > 0 && !(await mayChangeParentRoles(actor, domain, tx))) {
|
|
154
|
+
const names = [...new Set(parentChanges.map(role => role.name))].join(', ');
|
|
155
|
+
throw new shell_1.Refusal('role-belongs-to-parent', { roles: names }, `${names}: a role of the parent domain - only someone who decides roles there can give or take it.`);
|
|
156
|
+
}
|
|
85
157
|
user.roles = [...held, ...added];
|
|
86
158
|
const saved = await (0, shell_1.getRepository)(user_js_1.User, tx).save(user);
|
|
87
159
|
return { user: saved, added };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"assignable-roles.js","sourceRoot":"","sources":["../../server/utils/assignable-roles.ts"],"names":[],"mappings":"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","sourcesContent":["import { Brackets, EntityManager, In, SelectQueryBuilder, WhereExpressionBuilder } from 'typeorm'\n\nimport { Domain, getRepository, Refusal } from '@things-factory/shell'\n\nimport { GrantedRole } from '../service/granted-role/granted-role.js'\nimport { Role } from '../service/role/role.js'\nimport { User } from '../service/user/user.js'\n\n/*\n * The roles a domain can assign - and the one place a person is put into or taken out of them.\n *\n * ── The scope ─────────────────────────────────────────────────────────────\n * Three kinds: the domain's own roles, its parent's (one level, as `userRoles` · `myRoles`), and roles a\n * partner granted to it (`grantedRoles`). `User.roles` answers exactly this scope (cb9a41e040), so the\n * user screen's checkboxes and what can be saved are the same set.\n *\n * ── Why the ids are found again ──────────────────────────────────────────\n * `updateUserRoles` took the client's `selectedRoles` and concatenated them onto the user, and took its\n * `availableRoles` and removed every held role with one of those ids. Neither was looked at. A role id of\n * another tenant was added as sent, and another tenant's role id in `availableRoles` took that role away from\n * the person in the other domain. Both are found here, in this domain's scope, by id. A role to add that is not\n * there is refused by name (ADR-0103 decision 2); a role to remove that is not there is not this domain's to take.\n *\n * The access request grants through this too, so the two ways into a role are one.\n */\n\n/** Restricts `alias` (a Role) to what `domain` can assign. Parameters are named after the alias, so two uses do not collide. */\nexport function whereAssignableBy<T extends WhereExpressionBuilder>(qb: T, alias: string, domain: Domain): T {\n const domainIds = [domain.id, domain.parentId].filter(Boolean)\n const own = `${alias}_assignableDomainIds`\n const grantedTo = `${alias}_assignableGrantedTo`\n\n qb.andWhere(\n new Brackets(inner => {\n inner.where(`${alias}.domain IN (:...${own})`, { [own]: domainIds })\n inner.orWhere(sub => {\n const granted = (sub as unknown as SelectQueryBuilder<Role>)\n .subQuery()\n .select('GRANTED.role')\n .from(GrantedRole, 'GRANTED')\n .where(`GRANTED.domain = :${grantedTo}`, { [grantedTo]: domain.id })\n .getQuery()\n return `${alias}.id IN ${granted}`\n })\n })\n )\n return qb\n}\n\n/** The roles among `ids` that `domain` can assign. */\nexport async function findAssignableRoles(ids: string[], domain: Domain, tx?: EntityManager): Promise<Role[]> {\n const unique = [...new Set((ids || []).filter(Boolean))]\n if (unique.length === 0) return []\n\n const qb = getRepository(Role, tx).createQueryBuilder('ROLE').where({ id: In(unique) })\n return await whereAssignableBy(qb, 'ROLE', domain).getMany()\n}\n\n/** Refuses with the ids that are not this domain's to assign - a name and values, spoken by `SpeakRefusals`. */\nexport async function requireAssignableRoles(ids: string[], domain: Domain, tx?: EntityManager): Promise<Role[]> {\n const found = await findAssignableRoles(ids, domain, tx)\n const foundIds = new Set(found.map(role => role.id))\n const missing = [...new Set((ids || []).filter(Boolean))].filter(id => !foundIds.has(id))\n\n if (missing.length > 0) {\n throw new Refusal(\n 'role-not-assignable-here',\n { roles: missing.join(', ') },\n `This domain cannot assign the role(s) ${missing.join(', ')}.`\n )\n }\n return found\n}\n\n/**\n * Puts `user` into `add` and takes them out of `remove` - every id found again in `domain`'s scope first.\n * Roles the user holds outside that scope are not touched, whatever `remove` says.\n *\n * @returns the saved user, and the roles that were added (not already held).\n */\nexport async function assignRoles(\n user: User,\n { add = [], remove = [] }: { add?: string[]; remove?: string[] },\n domain: Domain,\n tx?: EntityManager\n): Promise<{ user: User; added: Role[] }> {\n const adding = await requireAssignableRoles(add, domain, tx)\n /*\n * `remove` is the caller's scope (\"every role this screen shows\"), not a demand - `user-detail.ts` builds it\n * from the list it read. Inside this domain's scope it is honoured; an id outside it is a role this domain\n * does not own, and it stays with the person.\n */\n const removing = await findAssignableRoles(remove, domain, tx)\n\n const addingIds = new Set(adding.map(role => role.id))\n const removingIds = new Set(removing.map(role => role.id).filter(id => !addingIds.has(id)))\n\n const held = (user.roles || []).filter(role => !removingIds.has(role.id))\n const heldIds = new Set(held.map(role => role.id))\n const added = adding.filter(role => !heldIds.has(role.id))\n\n user.roles = [...held, ...added]\n const saved = await getRepository(User, tx).save(user)\n return { user: saved, added }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"assignable-roles.js","sourceRoot":"","sources":["../../server/utils/assignable-roles.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2BA,8CAoBC;AAGD,kDAMC;AAGD,4DAKC;AAGD,wDAaC;AAeD,oDAOC;AAED,kCAyCC;AAjJD,qCAAiG;AAEjG,iDAAsE;AAEtE,6EAAqE;AACrE,qDAA8C;AAC9C,qDAA8C;AAE9C;;;;;;;;;;;;;;;;GAgBG;AAEH,gIAAgI;AAChI,SAAgB,iBAAiB,CAAmC,EAAK,EAAE,KAAa,EAAE,MAAc;IACtG,MAAM,SAAS,GAAG,CAAC,MAAM,CAAC,EAAE,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;IAC9D,MAAM,GAAG,GAAG,GAAG,KAAK,sBAAsB,CAAA;IAC1C,MAAM,SAAS,GAAG,GAAG,KAAK,sBAAsB,CAAA;IAEhD,EAAE,CAAC,QAAQ,CACT,IAAI,kBAAQ,CAAC,KAAK,CAAC,EAAE;QACnB,KAAK,CAAC,KAAK,CAAC,GAAG,KAAK,mBAAmB,GAAG,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,EAAE,SAAS,EAAE,CAAC,CAAA;QACpE,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE;YAClB,MAAM,OAAO,GAAI,GAA2C;iBACzD,QAAQ,EAAE;iBACV,MAAM,CAAC,cAAc,CAAC;iBACtB,IAAI,CAAC,6BAAW,EAAE,SAAS,CAAC;iBAC5B,KAAK,CAAC,qBAAqB,SAAS,EAAE,EAAE,EAAE,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC,EAAE,EAAE,CAAC;iBACnE,QAAQ,EAAE,CAAA;YACb,OAAO,GAAG,KAAK,UAAU,OAAO,EAAE,CAAA;QACpC,CAAC,CAAC,CAAA;IACJ,CAAC,CAAC,CACH,CAAA;IACD,OAAO,EAAE,CAAA;AACX,CAAC;AAED,sDAAsD;AAC/C,KAAK,UAAU,mBAAmB,CAAC,GAAa,EAAE,MAAc,EAAE,EAAkB;IACzF,MAAM,MAAM,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC,CAAC,GAAG,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,CAAA;IACxD,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAA;IAElC,MAAM,EAAE,GAAG,IAAA,qBAAa,EAAC,cAAI,EAAE,EAAE,CAAC,CAAC,kBAAkB,CAAC,MAAM,CAAC,CAAC,KAAK,CAAC,EAAE,EAAE,EAAE,IAAA,YAAE,EAAC,MAAM,CAAC,EAAE,CAAC,CAAA;IACvF,OAAO,MAAM,iBAAiB,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC,OAAO,EAAE,CAAA;AAC9D,CAAC;AAED,yFAAyF;AAClF,KAAK,UAAU,wBAAwB,CAAC,IAAY,EAAE,MAAc,EAAE,EAAkB;IAC7F,MAAM,IAAI,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAA;IAChC,IAAI,CAAC,IAAI;QAAE,OAAO,EAAE,CAAA;IACpB,MAAM,EAAE,GAAG,IAAA,qBAAa,EAAC,cAAI,EAAE,EAAE,CAAC,CAAC,kBAAkB,CAAC,MAAM,CAAC,CAAC,iBAAiB,CAAC,iBAAiB,EAAE,gBAAgB,CAAC,CAAC,KAAK,CAAC,mBAAmB,EAAE,EAAE,IAAI,EAAE,CAAC,CAAA;IACzJ,OAAO,MAAM,iBAAiB,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC,OAAO,EAAE,CAAA;AAC9D,CAAC;AAED,gHAAgH;AACzG,KAAK,UAAU,sBAAsB,CAAC,GAAa,EAAE,MAAc,EAAE,EAAkB;IAC5F,MAAM,KAAK,GAAG,MAAM,mBAAmB,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,CAAC,CAAA;IACxD,MAAM,QAAQ,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAA;IACpD,MAAM,OAAO,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC,CAAC,GAAG,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAA;IAEzF,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACvB,MAAM,IAAI,eAAO,CACf,0BAA0B,EAC1B,EAAE,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,EAC7B,yCAAyC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAC/D,CAAA;IACH,CAAC;IACD,OAAO,KAAK,CAAA;AACd,CAAC;AAED;;;;;GAKG;AACH;;;;;;GAMG;AACI,KAAK,UAAU,oBAAoB,CAAC,KAAuB,EAAE,MAAc,EAAE,EAAkB;IACpG,IAAI,CAAC,KAAK,EAAE,EAAE,IAAI,CAAC,MAAM,EAAE,QAAQ;QAAE,OAAO,KAAK,CAAA;IACjD,MAAM,MAAM,GAAG,MAAM,IAAA,qBAAa,EAAC,cAAM,EAAE,EAAE,CAAC,CAAC,SAAS,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,QAAQ,EAAE,CAAC,CAAA;IACjF,IAAI,CAAC,MAAM;QAAE,OAAO,KAAK,CAAA;IACzB,MAAM,EAAE,aAAa,EAAE,GAAG,wDAAa,uBAAuB,GAAC,CAAA;IAC/D,IAAI,MAAM,aAAa,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE,CAAC;QAAE,OAAO,IAAI,CAAA;IACvD,OAAO,MAAM,cAAI,CAAC,YAAY,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,CAAC,CAAA;AACnE,CAAC;AAEM,KAAK,UAAU,WAAW,CAC/B,IAAU,EACV,EAAE,GAAG,GAAG,EAAE,EAAE,MAAM,GAAG,EAAE,EAAyC,EAChE,MAAc,EACd,EAAkB,EAClB,KAAY;IAEZ,MAAM,MAAM,GAAG,MAAM,sBAAsB,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,CAAC,CAAA;IAC5D;;;;OAIG;IACH,MAAM,QAAQ,GAAG,MAAM,mBAAmB,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,CAAC,CAAA;IAE9D,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAA;IACtD,MAAM,WAAW,GAAG,IAAI,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;IAE3F,MAAM,IAAI,GAAG,CAAC,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,WAAW,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAA;IACzE,MAAM,OAAO,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAA;IAClD,MAAM,KAAK,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAA;IAE1D;;;;OAIG;IACH,MAAM,OAAO,GAAG,CAAC,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,WAAW,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAA;IAC3E,MAAM,aAAa,GAAG,CAAC,GAAG,KAAK,EAAE,GAAG,OAAO,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,MAAM,CAAC,QAAQ,IAAI,IAAI,CAAC,QAAQ,KAAK,MAAM,CAAC,QAAQ,CAAC,CAAA;IACjH,IAAI,aAAa,CAAC,MAAM,GAAG,CAAC,IAAI,CAAC,CAAC,MAAM,oBAAoB,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,CAAC,CAAC,EAAE,CAAC;QACjF,MAAM,KAAK,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC,aAAa,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QAC3E,MAAM,IAAI,eAAO,CACf,wBAAwB,EACxB,EAAE,KAAK,EAAE,KAAK,EAAE,EAChB,GAAG,KAAK,2FAA2F,CACpG,CAAA;IACH,CAAC;IAED,IAAI,CAAC,KAAK,GAAG,CAAC,GAAG,IAAI,EAAE,GAAG,KAAK,CAAC,CAAA;IAChC,MAAM,KAAK,GAAG,MAAM,IAAA,qBAAa,EAAC,cAAI,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IACtD,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,CAAA;AAC/B,CAAC","sourcesContent":["import { Brackets, EntityManager, In, SelectQueryBuilder, WhereExpressionBuilder } from 'typeorm'\n\nimport { Domain, getRepository, Refusal } from '@things-factory/shell'\n\nimport { GrantedRole } from '../service/granted-role/granted-role.js'\nimport { Role } from '../service/role/role.js'\nimport { User } from '../service/user/user.js'\n\n/*\n * The roles a domain can assign - and the one place a person is put into or taken out of them.\n *\n * ── The scope ─────────────────────────────────────────────────────────────\n * Three kinds: the domain's own roles, its parent's (one level, as `userRoles` · `myRoles`), and roles a\n * partner granted to it (`grantedRoles`). `User.roles` answers exactly this scope (cb9a41e040), so the\n * user screen's checkboxes and what can be saved are the same set.\n *\n * ── Why the ids are found again ──────────────────────────────────────────\n * `updateUserRoles` took the client's `selectedRoles` and concatenated them onto the user, and took its\n * `availableRoles` and removed every held role with one of those ids. Neither was looked at. A role id of\n * another tenant was added as sent, and another tenant's role id in `availableRoles` took that role away from\n * the person in the other domain. Both are found here, in this domain's scope, by id. A role to add that is not\n * there is refused by name (ADR-0103 decision 2); a role to remove that is not there is not this domain's to take.\n *\n * The access request grants through this too, so the two ways into a role are one.\n */\n\n/** Restricts `alias` (a Role) to what `domain` can assign. Parameters are named after the alias, so two uses do not collide. */\nexport function whereAssignableBy<T extends WhereExpressionBuilder>(qb: T, alias: string, domain: Domain): T {\n const domainIds = [domain.id, domain.parentId].filter(Boolean)\n const own = `${alias}_assignableDomainIds`\n const grantedTo = `${alias}_assignableGrantedTo`\n\n qb.andWhere(\n new Brackets(inner => {\n inner.where(`${alias}.domain IN (:...${own})`, { [own]: domainIds })\n inner.orWhere(sub => {\n const granted = (sub as unknown as SelectQueryBuilder<Role>)\n .subQuery()\n .select('GRANTED.role')\n .from(GrantedRole, 'GRANTED')\n .where(`GRANTED.domain = :${grantedTo}`, { [grantedTo]: domain.id })\n .getQuery()\n return `${alias}.id IN ${granted}`\n })\n })\n )\n return qb\n}\n\n/** The roles among `ids` that `domain` can assign. */\nexport async function findAssignableRoles(ids: string[], domain: Domain, tx?: EntityManager): Promise<Role[]> {\n const unique = [...new Set((ids || []).filter(Boolean))]\n if (unique.length === 0) return []\n\n const qb = getRepository(Role, tx).createQueryBuilder('ROLE').where({ id: In(unique) })\n return await whereAssignableBy(qb, 'ROLE', domain).getMany()\n}\n\n/** The roles `domain` can assign whose name is exactly `name`, with their privileges. */\nexport async function findAssignableRolesNamed(name: string, domain: Domain, tx?: EntityManager): Promise<Role[]> {\n const said = (name || '').trim()\n if (!said) return []\n const qb = getRepository(Role, tx).createQueryBuilder('ROLE').leftJoinAndSelect('ROLE.privileges', 'ROLE_PRIVILEGE').where('ROLE.name = :said', { said })\n return await whereAssignableBy(qb, 'ROLE', domain).getMany()\n}\n\n/** Refuses with the ids that are not this domain's to assign - a name and values, spoken by `SpeakRefusals`. */\nexport async function requireAssignableRoles(ids: string[], domain: Domain, tx?: EntityManager): Promise<Role[]> {\n const found = await findAssignableRoles(ids, domain, tx)\n const foundIds = new Set(found.map(role => role.id))\n const missing = [...new Set((ids || []).filter(Boolean))].filter(id => !foundIds.has(id))\n\n if (missing.length > 0) {\n throw new Refusal(\n 'role-not-assignable-here',\n { roles: missing.join(', ') },\n `This domain cannot assign the role(s) ${missing.join(', ')}.`\n )\n }\n return found\n}\n\n/**\n * Puts `user` into `add` and takes them out of `remove` - every id found again in `domain`'s scope first.\n * Roles the user holds outside that scope are not touched, whatever `remove` says.\n *\n * @returns the saved user, and the roles that were added (not already held).\n */\n/**\n * May `actor` change who holds the parent domain's roles? Only someone who could decide in the parent too: its owner,\n * or a holder of the role-giving privilege there (ADR-0103 decision 8).\n *\n * A role's holders are not tied to a domain - a parent's role given from a child counts wherever the person is a\n * member: in the parent, and in the parent's other children. So the child's own deciders cannot be enough.\n */\nexport async function mayChangeParentRoles(actor: User | undefined, domain: Domain, tx?: EntityManager): Promise<boolean> {\n if (!actor?.id || !domain?.parentId) return false\n const parent = await getRepository(Domain, tx).findOneBy({ id: domain.parentId })\n if (!parent) return false\n const { isDomainOwner } = await import('./domain-ownership.js')\n if (await isDomainOwner(parent, actor, tx)) return true\n return await User.hasPrivilege('mutation', 'user', parent, actor)\n}\n\nexport async function assignRoles(\n user: User,\n { add = [], remove = [] }: { add?: string[]; remove?: string[] },\n domain: Domain,\n tx?: EntityManager,\n actor?: User\n): Promise<{ user: User; added: Role[] }> {\n const adding = await requireAssignableRoles(add, domain, tx)\n /*\n * `remove` is the caller's scope (\"every role this screen shows\"), not a demand - `user-detail.ts` builds it\n * from the list it read. Inside this domain's scope it is honoured; an id outside it is a role this domain\n * does not own, and it stays with the person.\n */\n const removing = await findAssignableRoles(remove, domain, tx)\n\n const addingIds = new Set(adding.map(role => role.id))\n const removingIds = new Set(removing.map(role => role.id).filter(id => !addingIds.has(id)))\n\n const held = (user.roles || []).filter(role => !removingIds.has(role.id))\n const heldIds = new Set(held.map(role => role.id))\n const added = adding.filter(role => !heldIds.has(role.id))\n\n /*\n * Only what actually changes is asked: a screen sends every role it shows as `remove` and the kept ones again as `add`,\n * so a parent role left as it was is no change. Granted roles are not the parent's - their domain delegated them\n * to this one through grantRoles and takes them back the same way, so assigning them here is the contract.\n */\n const removed = (user.roles || []).filter(role => removingIds.has(role.id))\n const parentChanges = [...added, ...removed].filter(role => domain.parentId && role.domainId === domain.parentId)\n if (parentChanges.length > 0 && !(await mayChangeParentRoles(actor, domain, tx))) {\n const names = [...new Set(parentChanges.map(role => role.name))].join(', ')\n throw new Refusal(\n 'role-belongs-to-parent',\n { roles: names },\n `${names}: a role of the parent domain - only someone who decides roles there can give or take it.`\n )\n }\n\n user.roles = [...held, ...added]\n const saved = await getRepository(User, tx).save(user)\n return { user: saved, added }\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/auth-base",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.111",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"browser": "dist-client/index.js",
|
|
6
6
|
"things-factory": true,
|
|
@@ -48,5 +48,5 @@
|
|
|
48
48
|
"passport-jwt": "^4.0.0",
|
|
49
49
|
"passport-local": "^1.0.0"
|
|
50
50
|
},
|
|
51
|
-
"gitHead": "
|
|
51
|
+
"gitHead": "f33122c15e5b3e45393129e9418e2c367a273ade"
|
|
52
52
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the assistant reads to draft access decisions (ADR-0103 decision 6).
|
|
3
|
+
*
|
|
4
|
+
* Two facts are held here. The read tool answers only this domain, only to someone who may decide, never the
|
|
5
|
+
* caller's own requests. And a pending request handed over as the screen's context does not reach the model's
|
|
6
|
+
* prompt - which is why the tool exists; if that ever changes, this file says so.
|
|
7
|
+
*/
|
|
8
|
+
import { registryAssistantChat } from '../../ai-client-base/server/service/assistant/registry-assistant'
|
|
9
|
+
import { clearToolRegistry, registerToolCategory } from '../../ai-client-base/server/service/tool-registry/index.js'
|
|
10
|
+
|
|
11
|
+
import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
|
|
12
|
+
import { loadCompiled } from './compiled'
|
|
13
|
+
|
|
14
|
+
import uiEn from '../../auth-ui/translations/en.json'
|
|
15
|
+
import uiKo from '../../auth-ui/translations/ko.json'
|
|
16
|
+
|
|
17
|
+
const { Role } = loadCompiled('service/role/role')
|
|
18
|
+
const { Privilege } = loadCompiled('service/privilege/privilege')
|
|
19
|
+
const { User } = loadCompiled('service/user/user')
|
|
20
|
+
const { DomainOwner } = loadCompiled('service/domain-owner/domain-owner')
|
|
21
|
+
const { AccessRequest } = loadCompiled('service/access-request/access-request')
|
|
22
|
+
const access = loadCompiled('controllers/access-request')
|
|
23
|
+
const assistant = loadCompiled('ai-entry/access-assistant')
|
|
24
|
+
const targets = loadCompiled('ai-entry/access-entry-targets')
|
|
25
|
+
|
|
26
|
+
let domain: any
|
|
27
|
+
let other: any
|
|
28
|
+
let kim: any
|
|
29
|
+
let lee: any
|
|
30
|
+
let park: any
|
|
31
|
+
let owner: any
|
|
32
|
+
let worker: any
|
|
33
|
+
let otherAdmin: any
|
|
34
|
+
|
|
35
|
+
async function makeUser(name: string, domains: any[], roles: any[] = []) {
|
|
36
|
+
return await getRepository(User).save({
|
|
37
|
+
username: name, email: `${name}@example.com`, name, salt: User.generateSalt(), status: 'activated', domains, roles
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const ask = (user: any, privilege = 'mutation', inDomain = domain) =>
|
|
42
|
+
access.requestAccess({ category: 'warehouse', privilege, reason: 'needed for work' }, { state: { domain: inDomain, user, afterCommit: [] } } as any)
|
|
43
|
+
|
|
44
|
+
beforeAll(async () => {
|
|
45
|
+
await openAuthDatabase()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
afterAll(async () => {
|
|
49
|
+
await closeAuthDatabase()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
beforeEach(async () => {
|
|
53
|
+
await resetAuthDatabase()
|
|
54
|
+
;(process as any).domainOwnerGranted = async (d: any, u: any) =>
|
|
55
|
+
!!(await getRepository(DomainOwner).findOne({ where: { domain: { id: d.id }, user: { id: u.id } } }))
|
|
56
|
+
;(process as any).superUserGranted = async () => false
|
|
57
|
+
|
|
58
|
+
domain = await getRepository(Domain).findOneBy({ id: (await getRepository(Domain).save({ name: 'Plant A', subdomain: 'plant-a' })).id })
|
|
59
|
+
other = await getRepository(Domain).findOneBy({ id: (await getRepository(Domain).save({ name: 'Other', subdomain: 'other' })).id })
|
|
60
|
+
|
|
61
|
+
const mutation = await getRepository(Privilege).save({ name: 'mutation', category: 'warehouse' })
|
|
62
|
+
await getRepository(Privilege).save({ name: 'query', category: 'warehouse' })
|
|
63
|
+
const grant = await getRepository(Privilege).save({ name: 'mutation', category: 'user' })
|
|
64
|
+
await getRepository(Role).save({ name: 'inbound', domain, privileges: [mutation] })
|
|
65
|
+
const admins = await getRepository(Role).save({ name: 'admins', domain, privileges: [grant] })
|
|
66
|
+
const otherAdmins = await getRepository(Role).save({ name: 'admins', domain: other, privileges: [grant] })
|
|
67
|
+
|
|
68
|
+
kim = await makeUser('kim', [domain])
|
|
69
|
+
lee = await makeUser('lee', [domain])
|
|
70
|
+
park = await makeUser('park', [domain], [admins])
|
|
71
|
+
owner = await makeUser('owner', [domain])
|
|
72
|
+
worker = await makeUser('worker', [domain])
|
|
73
|
+
otherAdmin = await makeUser('otheradmin', [other], [otherAdmins])
|
|
74
|
+
await getRepository(DomainOwner).save({ domain, user: owner })
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
delete (process as any).domainOwnerGranted
|
|
79
|
+
delete (process as any).superUserGranted
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('listPendingAccessRequests', () => {
|
|
83
|
+
it('★ a decider reads this domain\'s waiting requests with the roles that grant each', async () => {
|
|
84
|
+
await ask(kim)
|
|
85
|
+
const answer = await assistant.pendingRequestsToDecide({ domain, user: park })
|
|
86
|
+
expect(answer.requests).toEqual([
|
|
87
|
+
expect.objectContaining({ requester: 'kim', privilege: 'warehouse:mutation', roles: [{ name: 'inbound', privilegeCount: 1 }] })
|
|
88
|
+
])
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('★ another domain\'s requests are not read - even by a decider there', async () => {
|
|
92
|
+
await ask(kim)
|
|
93
|
+
const there = await assistant.pendingRequestsToDecide({ domain: other, user: otherAdmin })
|
|
94
|
+
expect(there.requests).toEqual([])
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('★ someone who may not decide is refused - nothing is read', async () => {
|
|
98
|
+
await ask(kim)
|
|
99
|
+
expect(await assistant.pendingRequestsToDecide({ domain, user: worker })).toEqual({ refused: 'not-a-decider' })
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('★ the caller\'s own requests are left out - they cannot decide them', async () => {
|
|
103
|
+
await ask(kim)
|
|
104
|
+
await ask(owner, 'query')
|
|
105
|
+
const answer = await assistant.pendingRequestsToDecide({ domain, user: owner })
|
|
106
|
+
expect(answer.requests.map((r: any) => r.requester)).toEqual(['kim'])
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('decided and expired requests are left out', async () => {
|
|
110
|
+
const decided = await ask(kim)
|
|
111
|
+
await access.decideAccessRequest({ requestId: decided.id, decision: 'DENY', reason: 'no' }, { state: { domain, user: park, afterCommit: [] } } as any)
|
|
112
|
+
const late = await ask(lee)
|
|
113
|
+
await getRepository(AccessRequest).update({ id: late.id }, { expiresAt: new Date(Date.now() - 1000) })
|
|
114
|
+
expect((await assistant.pendingRequestsToDecide({ domain, user: park })).requests).toEqual([])
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('the declarations fit together', () => {
|
|
119
|
+
afterEach(() => clearToolRegistry())
|
|
120
|
+
|
|
121
|
+
it('the category registers, the mode names it and the decision target, the words exist', () => {
|
|
122
|
+
const category = assistant.accessToolCategory()
|
|
123
|
+
expect(() => registerToolCategory(category)).not.toThrow()
|
|
124
|
+
const mode = assistant.accessAssistantMode()
|
|
125
|
+
expect(mode.toolCategories).toEqual([category.name])
|
|
126
|
+
expect(mode.entryTargets).toEqual([targets.AUTH_ACCESS_DECISION])
|
|
127
|
+
expect(mode.subjects).toEqual([assistant.ACCESS_REQUESTS_SUBJECT])
|
|
128
|
+
for (const dict of [uiEn, uiKo] as Record<string, string>[])
|
|
129
|
+
for (const key of [mode.labelKey, mode.placeholderKey]) expect([key, typeof dict[key]]).toEqual([key, 'string'])
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('★ requests handed over as the screen\'s context do not reach the model\'s prompt - the tool is how it reads them', async () => {
|
|
133
|
+
registerToolCategory(assistant.accessToolCategory())
|
|
134
|
+
const seen: any[] = []
|
|
135
|
+
const client = {
|
|
136
|
+
chatWithTools: async (messages: any, tools: any[], options: any) => {
|
|
137
|
+
seen.push({ messages, tools, options })
|
|
138
|
+
return { text: 'ok', toolCalls: [], stopReason: 'end_turn' }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
await registryAssistantChat({
|
|
142
|
+
client,
|
|
143
|
+
messages: [{ role: 'user', content: 'sort out the access requests' }],
|
|
144
|
+
systemPrompt: assistant.accessAssistantMode().systemPrompt,
|
|
145
|
+
toolCategories: [assistant.AUTH_ACCESS_TOOLS],
|
|
146
|
+
hostContext: { pendingRequests: ['MARKER-kim-warehouse-mutation'] },
|
|
147
|
+
state: { domain, user: park },
|
|
148
|
+
toolAccess: async () => true
|
|
149
|
+
} as any)
|
|
150
|
+
|
|
151
|
+
expect(JSON.stringify(seen[0].options)).not.toContain('MARKER-kim')
|
|
152
|
+
expect(JSON.stringify(seen[0].messages)).not.toContain('MARKER-kim')
|
|
153
|
+
expect(seen[0].tools.map((t: any) => t.name)).toEqual(['listPendingAccessRequests'])
|
|
154
|
+
})
|
|
155
|
+
})
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The access request's AI 입력 declarations (ADR-0103 decision 4).
|
|
3
|
+
*
|
|
4
|
+
* The model drafts and a person presses; the card's check is the review the list screen asks. That last point is
|
|
5
|
+
* what this file holds above all: the card's findings for a decision equal `reviewDecision` for the same decision.
|
|
6
|
+
*
|
|
7
|
+
* The declarations are read from the compiled package so their check reaches the same entities as the database.
|
|
8
|
+
* The assistant's own validation is asked through its source file, which needs only `graphql`.
|
|
9
|
+
*/
|
|
10
|
+
import { parse } from 'graphql'
|
|
11
|
+
|
|
12
|
+
import { clearEntryTargets, declareEntryTarget } from '../../ai-assistant/server/service/data-entry/entry-target'
|
|
13
|
+
|
|
14
|
+
import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
|
|
15
|
+
import { loadCompiled } from './compiled'
|
|
16
|
+
|
|
17
|
+
import uiEn from '../../auth-ui/translations/en.json'
|
|
18
|
+
import uiKo from '../../auth-ui/translations/ko.json'
|
|
19
|
+
|
|
20
|
+
const { Role } = loadCompiled('service/role/role')
|
|
21
|
+
const { Privilege } = loadCompiled('service/privilege/privilege')
|
|
22
|
+
const { User } = loadCompiled('service/user/user')
|
|
23
|
+
const access = loadCompiled('controllers/access-request')
|
|
24
|
+
const targets = loadCompiled('ai-entry/access-entry-targets')
|
|
25
|
+
|
|
26
|
+
const rootFields = (document: string) =>
|
|
27
|
+
((parse(document).definitions[0] as any).selectionSet.selections as any[]).map(one => one.name.value)
|
|
28
|
+
|
|
29
|
+
const row = (values: Record<string, unknown>, index = 0) => ({ index, values, sources: [], issues: [] })
|
|
30
|
+
|
|
31
|
+
describe('the declarations', () => {
|
|
32
|
+
afterEach(() => clearEntryTargets())
|
|
33
|
+
|
|
34
|
+
it('are accepted by the assistant as declared', () => {
|
|
35
|
+
for (const target of targets.accessEntryTargets()) expect(() => declareEntryTarget(target)).not.toThrow()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('★ the decision card writes through decideAccessRequest - the person presses it; nothing else is a door', () => {
|
|
39
|
+
const decision = targets.accessDecisionEntryTarget()
|
|
40
|
+
expect(rootFields(decision.mutation)).toEqual(['decideAccessRequest'])
|
|
41
|
+
expect(rootFields(decision.fields.find((f: any) => f.name === 'request').reference.query)).toEqual(['accessRequests'])
|
|
42
|
+
expect(rootFields(targets.accessRequestEntryTarget().mutation)).toEqual(['requestAccess'])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('a denial carries no role, a grant carries its role id', () => {
|
|
46
|
+
expect(targets.decisionOf(row({ request: { id: 'q' }, decision: 'DENY', role: { id: 'r' }, reason: 'no' }))).toEqual({
|
|
47
|
+
requestId: 'q',
|
|
48
|
+
decision: 'DENY',
|
|
49
|
+
roleId: undefined,
|
|
50
|
+
reason: 'no'
|
|
51
|
+
})
|
|
52
|
+
expect(targets.decisionOf(row({ request: { id: 'q' }, decision: 'GRANT', role: { id: 'r' } })).roleId).toBe('r')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('a request is named by person or privilege; only pending ones; several stay several', () => {
|
|
56
|
+
const items = [
|
|
57
|
+
{ id: '1', state: 'PENDING', category: 'warehouse', privilege: 'mutation', requester: { name: '김민수' } },
|
|
58
|
+
{ id: '2', state: 'PENDING', category: 'board', privilege: 'query', requester: { name: '김민지' } },
|
|
59
|
+
{ id: '3', state: 'GRANTED', category: 'warehouse', privilege: 'mutation', requester: { name: '김민수' } }
|
|
60
|
+
]
|
|
61
|
+
expect(targets.requestsNamed(items, '김민수').map((r: any) => r.id)).toEqual(['1'])
|
|
62
|
+
expect(targets.requestsNamed(items, '김민').map((r: any) => r.id)).toEqual(['1', '2'])
|
|
63
|
+
expect(targets.requestsNamed(items, 'board:query').map((r: any) => r.label)).toEqual(['김민지 · board:query'])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('every word the card shows is in the screen words', () => {
|
|
67
|
+
const decision = targets.accessDecisionEntryTarget()
|
|
68
|
+
const keys = [
|
|
69
|
+
decision.labelKey,
|
|
70
|
+
targets.accessRequestEntryTarget().labelKey,
|
|
71
|
+
...[...decision.fields, ...targets.accessRequestEntryTarget().fields].map((f: any) => f.labelKey),
|
|
72
|
+
'access-request.decision.grant',
|
|
73
|
+
'access-request.decision.deny',
|
|
74
|
+
'access-request.issue.not-by-role'
|
|
75
|
+
]
|
|
76
|
+
for (const dict of [uiEn, uiKo] as Record<string, string>[]) for (const key of keys) expect([key, typeof dict[key]]).toEqual([key, 'string'])
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('the card says what the screen says', () => {
|
|
81
|
+
let domain: any
|
|
82
|
+
let kim: any
|
|
83
|
+
let park: any
|
|
84
|
+
let inbound: any
|
|
85
|
+
let manager: any
|
|
86
|
+
|
|
87
|
+
beforeAll(async () => {
|
|
88
|
+
await openAuthDatabase()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
afterAll(async () => {
|
|
92
|
+
await closeAuthDatabase()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
beforeEach(async () => {
|
|
96
|
+
await resetAuthDatabase()
|
|
97
|
+
const saved = await getRepository(Domain).save({ name: 'Plant A', subdomain: 'plant-a' })
|
|
98
|
+
domain = await getRepository(Domain).findOneBy({ id: saved.id })
|
|
99
|
+
const mutation = await getRepository(Privilege).save({ name: 'mutation', category: 'warehouse' })
|
|
100
|
+
const query = await getRepository(Privilege).save({ name: 'query', category: 'warehouse' })
|
|
101
|
+
const grant = await getRepository(Privilege).save({ name: 'mutation', category: 'user' })
|
|
102
|
+
inbound = await getRepository(Role).save({ name: 'inbound', domain, privileges: [mutation] })
|
|
103
|
+
manager = await getRepository(Role).save({ name: 'manager', domain, privileges: [mutation, query, grant] })
|
|
104
|
+
const admins = await getRepository(Role).save({ name: 'admins', domain, privileges: [grant] })
|
|
105
|
+
const make = (name: string, roles: any[]) =>
|
|
106
|
+
getRepository(User).save({ username: name, email: `${name}@example.com`, name, salt: User.generateSalt(), status: 'activated', domains: [domain], roles })
|
|
107
|
+
kim = await make('kim', [])
|
|
108
|
+
park = await make('park', [admins])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const state = (user: any) => ({ domain, user })
|
|
112
|
+
|
|
113
|
+
it('★ the card\'s findings are reviewDecision\'s, for grant and for deny', async () => {
|
|
114
|
+
const request = await access.requestAccess({ category: 'warehouse', privilege: 'mutation', reason: 'inbound' }, { state: { ...state(kim), afterCommit: [] } } as any)
|
|
115
|
+
const rows = [
|
|
116
|
+
row({ request: { id: request.id }, decision: 'GRANT', role: { id: manager.id } }, 0),
|
|
117
|
+
row({ request: { id: request.id }, decision: 'GRANT', role: { id: inbound.id } }, 1),
|
|
118
|
+
row({ request: { id: request.id }, decision: 'DENY', reason: '' }, 2)
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
const card = await targets.accessDecisionEntryTarget().check(rows, { state: state(park) })
|
|
122
|
+
|
|
123
|
+
for (const [index, one] of rows.entries()) {
|
|
124
|
+
const screen = await access.reviewDecision(targets.decisionOf(one), { state: state(park) } as any)
|
|
125
|
+
expect(card[index].map((issue: any) => [issue.code, issue.blocking])).toEqual(screen.map((issue: any) => [issue.code, issue.blocking]))
|
|
126
|
+
}
|
|
127
|
+
expect(card[0].map((issue: any) => issue.code)).toContain('role-grants-admin')
|
|
128
|
+
expect(card[2].map((issue: any) => issue.code)).toEqual(['deny-needs-reason'])
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('★ the request card refuses what the door would - already held', async () => {
|
|
132
|
+
const lee = await getRepository(User).save({
|
|
133
|
+
username: 'lee', email: 'lee@example.com', name: 'lee', salt: User.generateSalt(), status: 'activated', domains: [domain], roles: [inbound]
|
|
134
|
+
})
|
|
135
|
+
const [issues] = await targets.accessRequestEntryTarget().check([row({ category: 'warehouse', privilege: 'mutation' })], { state: state(lee) })
|
|
136
|
+
expect(issues).toEqual([expect.objectContaining({ code: 'already-holds', blocking: true })])
|
|
137
|
+
})
|
|
138
|
+
})
|