@things-factory/auth-base 10.1.107 → 10.1.113
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 +8 -0
- package/dist-server/ai-entry/access-assistant.js +9 -1
- package/dist-server/ai-entry/access-assistant.js.map +1 -1
- package/dist-server/ai-entry/access-entry-targets.js +4 -3
- package/dist-server/ai-entry/access-entry-targets.js.map +1 -1
- package/dist-server/controllers/access-request.d.ts +2 -0
- package/dist-server/controllers/access-request.js +56 -18
- package/dist-server/controllers/access-request.js.map +1 -1
- package/dist-server/service/access-request/access-request-query.d.ts +2 -0
- package/dist-server/service/access-request/access-request-query.js +22 -5
- package/dist-server/service/access-request/access-request-query.js.map +1 -1
- package/dist-server/service/appliance/appliance-query.js +4 -2
- package/dist-server/service/appliance/appliance-query.js.map +1 -1
- package/dist-server/service/application/application-query.js +2 -1
- package/dist-server/service/application/application-query.js.map +1 -1
- package/dist-server/service/auth-provider/auth-provider-query.js +2 -1
- package/dist-server/service/auth-provider/auth-provider-query.js.map +1 -1
- package/dist-server/service/index.d.ts +1 -1
- package/dist-server/service/invitation/invitation-query.js +2 -1
- package/dist-server/service/invitation/invitation-query.js.map +1 -1
- package/dist-server/service/login-history/login-history-query.js +8 -4
- package/dist-server/service/login-history/login-history-query.js.map +1 -1
- package/dist-server/service/open-fields.js +1 -0
- package/dist-server/service/open-fields.js.map +1 -1
- package/dist-server/service/partner/partner-query.js +2 -1
- package/dist-server/service/partner/partner-query.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-request-db.test.ts +44 -0
- package/tests/access-request-pg.test.ts +11 -0
- package/tests/access-request-review.test.ts +8 -0
- package/tests/assignable-roles-db.test.ts +79 -1
- package/tests/list-inheritance-db.test.ts +149 -0
- package/translations/en.json +2 -1
- package/translations/ja.json +2 -1
- package/translations/ko.json +2 -1
- package/translations/ms.json +2 -1
- package/translations/zh.json +2 -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":"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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/** 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.113",
|
|
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": "9da1e467ddce23e600e64f08454289cb3b806212"
|
|
52
52
|
}
|
|
@@ -271,3 +271,47 @@ describe('asking is not a reflex (questions Q9)', () => {
|
|
|
271
271
|
expect(await new AccessRequestQuery().accessRequestDeciderCount(contextOf(park))).toBe(1)
|
|
272
272
|
})
|
|
273
273
|
})
|
|
274
|
+
|
|
275
|
+
describe('found in review (2026-10-02)', () => {
|
|
276
|
+
const { AccessRequestQuery } = loadCompiled('service/access-request/access-request-query')
|
|
277
|
+
|
|
278
|
+
it('★ the list is this domain\'s only - asking for the parent\'s with inherited: Only reads nothing', async () => {
|
|
279
|
+
await access.requestAccess(ASK, contextOf(kim))
|
|
280
|
+
const savedChild = await getRepository(Domain).save({ name: 'Plant A-1', subdomain: 'plant-a-1', parent: domain })
|
|
281
|
+
const child = await getRepository(Domain).findOneBy({ id: savedChild.id })
|
|
282
|
+
const childAdmin = await makeUser('childadmin', [child])
|
|
283
|
+
const answer = await new AccessRequestQuery().accessRequests({ inherited: 'Only' } as any, { state: { domain: child, user: childAdmin } } as any)
|
|
284
|
+
expect(answer.items).toEqual([])
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('★ ask, withdraw, ask again: one row, and the deciders are told once', async () => {
|
|
288
|
+
const first = contextOf(kim)
|
|
289
|
+
const request = await access.requestAccess(ASK, first)
|
|
290
|
+
await commit(first)
|
|
291
|
+
await access.withdrawAccessRequest(request.id, contextOf(kim))
|
|
292
|
+
const again = contextOf(kim)
|
|
293
|
+
const back = await access.requestAccess({ ...ASK, reason: 'needed after all' }, again)
|
|
294
|
+
await commit(again)
|
|
295
|
+
|
|
296
|
+
expect(back.id).toBe(request.id)
|
|
297
|
+
expect(back.state).toBe('PENDING')
|
|
298
|
+
expect(await getRepository(AccessRequest).count()).toBe(1)
|
|
299
|
+
expect(first.notify).toHaveBeenCalledTimes(1)
|
|
300
|
+
expect(again.notify).not.toHaveBeenCalled()
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('★ a decision may name a parent domain\'s role - the scope the list screen offers', async () => {
|
|
304
|
+
const savedChild = await getRepository(Domain).save({ name: 'Plant A-2', subdomain: 'plant-a-2', parent: domain })
|
|
305
|
+
const child = await getRepository(Domain).findOneBy({ id: savedChild.id })
|
|
306
|
+
const found = await new AccessRequestQuery().assignableRolesNamed('inbound', { state: { domain: child, user: owner } } as any)
|
|
307
|
+
expect(found.map((r: any) => r.id)).toEqual([inbound.id])
|
|
308
|
+
expect(await new AccessRequestQuery().assignableRolesNamed('inbou', { state: { domain: child, user: owner } } as any)).toEqual([])
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('my requests carry their domain and requester - non-null fields are loaded', async () => {
|
|
312
|
+
await access.requestAccess(ASK, contextOf(kim))
|
|
313
|
+
const [mine] = await new AccessRequestQuery().myAccessRequests(contextOf(kim))
|
|
314
|
+
expect(mine.domain?.id).toBe(domain.id)
|
|
315
|
+
expect(mine.requester?.id).toBe(kim.id)
|
|
316
|
+
})
|
|
317
|
+
})
|
|
@@ -62,6 +62,17 @@ onPostgres('on postgres', () => {
|
|
|
62
62
|
expect(holds).toBe(stored.state === 'GRANTED')
|
|
63
63
|
})
|
|
64
64
|
|
|
65
|
+
it('★ a grant closes the requester\'s other requests it answers - read inside the transaction, not the default connection', async () => {
|
|
66
|
+
const query = await getRepository(Privilege).save({ name: 'query', category: 'warehouse' })
|
|
67
|
+
const both = await getRepository(Role).save({ name: 'both', domain, privileges: [query, await getRepository(Privilege).findOneBy({ name: 'mutation', category: 'warehouse' })] })
|
|
68
|
+
const first = await inTransaction(kim, context => access.requestAccess({ category: 'warehouse', privilege: 'mutation', reason: 'inbound' }, context))
|
|
69
|
+
const second = await inTransaction(kim, context => access.requestAccess({ category: 'warehouse', privilege: 'query', reason: 'stock' }, context))
|
|
70
|
+
|
|
71
|
+
await inTransaction(owner, context => access.decideAccessRequest({ requestId: first.id, decision: 'GRANT', roleId: both.id }, context))
|
|
72
|
+
|
|
73
|
+
expect((await getRepository(AccessRequest).findOneBy({ id: second.id })).state).toBe('GRANTED')
|
|
74
|
+
})
|
|
75
|
+
|
|
65
76
|
it('★ the same person asking twice at once - one pending request, not two', async () => {
|
|
66
77
|
await Promise.allSettled([
|
|
67
78
|
inTransaction(kim, context => access.requestAccess({ category: 'warehouse', privilege: 'mutation', reason: 'inbound' }, context)),
|
|
@@ -153,3 +153,11 @@ describe('helpers', () => {
|
|
|
153
153
|
expect(effectiveState('GRANTED', new Date(0), NOW)).toBe('GRANTED')
|
|
154
154
|
})
|
|
155
155
|
})
|
|
156
|
+
|
|
157
|
+
describe('the parent domain\'s role (ADR-0103 decision 8)', () => {
|
|
158
|
+
it('★ blocks when the decider does not decide in the parent, passes when they do', () => {
|
|
159
|
+
expect(codes(facts({ roleIsParents: true, deciderMayChangeParentRoles: false }))).toContain('⛔role-belongs-to-parent')
|
|
160
|
+
expect(codes(facts({ roleIsParents: true, deciderMayChangeParentRoles: true }))).not.toContain('⛔role-belongs-to-parent')
|
|
161
|
+
expect(codes(facts({ roleIsParents: false }))).not.toContain('⛔role-belongs-to-parent')
|
|
162
|
+
})
|
|
163
|
+
})
|
|
@@ -15,6 +15,8 @@ const { GrantedRole } = loadCompiled('service/granted-role/granted-role')
|
|
|
15
15
|
const { User } = loadCompiled('service/user/user')
|
|
16
16
|
const { UserMutation } = loadCompiled('service/user/user-mutation')
|
|
17
17
|
const { assignRoles } = loadCompiled('utils/assignable-roles')
|
|
18
|
+
const { DomainOwner } = loadCompiled('service/domain-owner/domain-owner')
|
|
19
|
+
const { Privilege } = loadCompiled('service/privilege/privilege')
|
|
18
20
|
|
|
19
21
|
let parent: any
|
|
20
22
|
let child: any
|
|
@@ -80,7 +82,9 @@ describe('what a domain can put a person into', () => {
|
|
|
80
82
|
await getRepository(GrantedRole).save({ role: granted, domain: child })
|
|
81
83
|
const user = await makeUser([child], [])
|
|
82
84
|
|
|
83
|
-
const
|
|
85
|
+
const parentOwner = await makeUser([parent], [])
|
|
86
|
+
await getRepository(DomainOwner).save({ domain: parent, user: parentOwner })
|
|
87
|
+
const { added } = await assignRoles(user, { add: [own.id, inherited.id, granted.id] }, child, undefined, parentOwner)
|
|
84
88
|
|
|
85
89
|
expect(added.map((role: any) => role.name).sort()).toEqual(['operator', 'partner', 'viewer'])
|
|
86
90
|
expect(await heldRoleNames(user)).toEqual(['operator', 'partner', 'viewer'])
|
|
@@ -139,3 +143,77 @@ describe('updateUserRoles goes through the same function', () => {
|
|
|
139
143
|
expect(await heldRoleNames(user)).toEqual(['b'])
|
|
140
144
|
})
|
|
141
145
|
})
|
|
146
|
+
|
|
147
|
+
describe('the parent domain\'s roles - only someone who decides there (ADR-0103 decision 8)', () => {
|
|
148
|
+
let parentRole: any
|
|
149
|
+
let childAdmin: any
|
|
150
|
+
let user: any
|
|
151
|
+
|
|
152
|
+
beforeEach(async () => {
|
|
153
|
+
const grant = await getRepository(Privilege).save({ name: 'mutation', category: 'user' })
|
|
154
|
+
const childAdmins = await getRepository(Role).save({ name: 'child-admins', domain: child, privileges: [grant] })
|
|
155
|
+
parentRole = await getRepository(Role).save({ name: 'hq-approver', domain: parent })
|
|
156
|
+
childAdmin = await makeUser([child], [childAdmins])
|
|
157
|
+
user = await makeUser([child], [])
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('★ a child domain\'s decider cannot give the parent\'s role - refused by name', async () => {
|
|
161
|
+
await expect(assignRoles(user, { add: [parentRole.id] }, child, undefined, childAdmin)).rejects.toMatchObject({
|
|
162
|
+
code: 'role-belongs-to-parent',
|
|
163
|
+
params: { roles: 'hq-approver' }
|
|
164
|
+
})
|
|
165
|
+
expect(await heldRoleNames(user)).toEqual([])
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('★ nor take it away', async () => {
|
|
169
|
+
const holder = await makeUser([child], [parentRole])
|
|
170
|
+
await expect(assignRoles(holder, { remove: [parentRole.id] }, child, undefined, childAdmin)).rejects.toMatchObject({ code: 'role-belongs-to-parent' })
|
|
171
|
+
expect(await heldRoleNames(holder)).toEqual(['hq-approver'])
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('★ the parent\'s owner can - from the child', async () => {
|
|
175
|
+
const parentOwner = await makeUser([parent], [])
|
|
176
|
+
await getRepository(DomainOwner).save({ domain: parent, user: parentOwner })
|
|
177
|
+
await assignRoles(user, { add: [parentRole.id] }, child, undefined, parentOwner)
|
|
178
|
+
expect(await heldRoleNames(user)).toEqual(['hq-approver'])
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('★ a holder of the role-giving privilege in the parent can', async () => {
|
|
182
|
+
const grant = await getRepository(Privilege).findOneBy({ name: 'mutation', category: 'user' })
|
|
183
|
+
const hqAdmins = await getRepository(Role).save({ name: 'hq-admins', domain: parent, privileges: [grant] })
|
|
184
|
+
const hqAdmin = await makeUser([parent, child], [hqAdmins])
|
|
185
|
+
await assignRoles(user, { add: [parentRole.id] }, child, undefined, hqAdmin)
|
|
186
|
+
expect(await heldRoleNames(user)).toEqual(['hq-approver'])
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('a parent role left as it was is no change - the user screen sends every shown role, nothing is refused', async () => {
|
|
190
|
+
const own = await getRepository(Role).save({ name: 'operator', domain: child })
|
|
191
|
+
const holder = await makeUser([child], [parentRole])
|
|
192
|
+
await assignRoles(holder, { add: [parentRole.id, own.id], remove: [parentRole.id, own.id] }, child, undefined, childAdmin)
|
|
193
|
+
expect(await heldRoleNames(holder)).toEqual(['hq-approver', 'operator'])
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('★ updateUserRoles carries the caller - a child admin adding the parent\'s role is refused', async () => {
|
|
197
|
+
const mutation = new UserMutation()
|
|
198
|
+
await expect(
|
|
199
|
+
mutation.updateUserRoles(user.username, [{ id: parentRole.id }], [{ id: parentRole.id }], { state: { domain: child, user: childAdmin }, t: (k: string) => k } as any)
|
|
200
|
+
).rejects.toMatchObject({ code: 'role-belongs-to-parent' })
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('★ updateUserRoles carries the caller - the parent\'s owner adding it from the child passes', async () => {
|
|
204
|
+
const parentOwner = await makeUser([parent, child], [])
|
|
205
|
+
await getRepository(DomainOwner).save({ domain: parent, user: parentOwner })
|
|
206
|
+
await new UserMutation().updateUserRoles(user.username, [{ id: parentRole.id }], [{ id: parentRole.id }], {
|
|
207
|
+
state: { domain: child, user: parentOwner },
|
|
208
|
+
t: (k: string) => k
|
|
209
|
+
} as any)
|
|
210
|
+
expect(await heldRoleNames(user)).toEqual(['hq-approver'])
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('a granted role is the granting domain\'s delegation - a child decider assigns it', async () => {
|
|
214
|
+
const partnerRole = await getRepository(Role).save({ name: 'partner', domain: stranger })
|
|
215
|
+
await getRepository(GrantedRole).save({ role: partnerRole, domain: child })
|
|
216
|
+
await assignRoles(user, { add: [partnerRole.id] }, child, undefined, childAdmin)
|
|
217
|
+
expect(await heldRoleNames(user)).toEqual(['partner'])
|
|
218
|
+
})
|
|
219
|
+
})
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A list reads this domain's rows only - whatever `inherited` the caller sent (2026-10-02).
|
|
3
|
+
*
|
|
4
|
+
* `getQueryBuilderFromListParams` honours `inherited: Include | Only`, so a list that hands the client's ListParam
|
|
5
|
+
* through lets a child domain read its parent's rows. For these lists a parent's rows are another tenant's
|
|
6
|
+
* configuration or people, and no screen asks for them: the query pins `inherited` to None. The same pin as
|
|
7
|
+
* `accessRequests` (access-request-db.test.ts).
|
|
8
|
+
*
|
|
9
|
+
* Roles are not here - the role screens ask for the parent's roles on purpose (assignable scope, docs/design/auth-rebuild.md).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
|
|
13
|
+
import { loadCompiled } from './compiled'
|
|
14
|
+
|
|
15
|
+
const { User } = loadCompiled('service/user/user')
|
|
16
|
+
const { AuthProvider } = loadCompiled('service/auth-provider/auth-provider')
|
|
17
|
+
const { AuthProviderQuery } = loadCompiled('service/auth-provider/auth-provider-query')
|
|
18
|
+
const { Appliance } = loadCompiled('service/appliance/appliance')
|
|
19
|
+
const { ApplianceQuery } = loadCompiled('service/appliance/appliance-query')
|
|
20
|
+
const { Application } = loadCompiled('service/application/application')
|
|
21
|
+
const { ApplicationQuery } = loadCompiled('service/application/application-query')
|
|
22
|
+
const { Invitation } = loadCompiled('service/invitation/invitation')
|
|
23
|
+
const { InvitationStatus } = loadCompiled('utils/invitation-state')
|
|
24
|
+
const { InvitationQuery } = loadCompiled('service/invitation/invitation-query')
|
|
25
|
+
const { Partner } = loadCompiled('service/partner/partner')
|
|
26
|
+
const { PartnerQuery } = loadCompiled('service/partner/partner-query')
|
|
27
|
+
const { LoginHistory } = loadCompiled('service/login-history/login-history')
|
|
28
|
+
const { LoginHistoryQuery } = loadCompiled('service/login-history/login-history-query')
|
|
29
|
+
|
|
30
|
+
let parent: any
|
|
31
|
+
let child: any
|
|
32
|
+
let partnerDomain: any
|
|
33
|
+
let parentUser: any
|
|
34
|
+
let childUser: any
|
|
35
|
+
|
|
36
|
+
async function makeUser(name: string, domains: any[]) {
|
|
37
|
+
return await getRepository(User).save({
|
|
38
|
+
username: name,
|
|
39
|
+
email: `${name}@example.com`,
|
|
40
|
+
name,
|
|
41
|
+
salt: User.generateSalt(),
|
|
42
|
+
status: 'activated',
|
|
43
|
+
domains
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const from = (domain: any, user: any) => ({ state: { domain, user } }) as any
|
|
48
|
+
const ONLY = { inherited: 'Only' } as any
|
|
49
|
+
|
|
50
|
+
beforeAll(async () => {
|
|
51
|
+
await openAuthDatabase()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
afterAll(async () => {
|
|
55
|
+
await closeAuthDatabase()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
beforeEach(async () => {
|
|
59
|
+
await resetAuthDatabase()
|
|
60
|
+
|
|
61
|
+
const savedParent = await getRepository(Domain).save({ name: 'Plant A', subdomain: 'plant-a' })
|
|
62
|
+
parent = await getRepository(Domain).findOneBy({ id: savedParent.id })
|
|
63
|
+
const savedChild = await getRepository(Domain).save({ name: 'Plant A-1', subdomain: 'plant-a-1', parent })
|
|
64
|
+
child = await getRepository(Domain).findOneBy({ id: savedChild.id })
|
|
65
|
+
partnerDomain = await getRepository(Domain).save({ name: 'Vendor', subdomain: 'vendor' })
|
|
66
|
+
|
|
67
|
+
parentUser = await makeUser('parent-admin', [parent])
|
|
68
|
+
childUser = await makeUser('child-admin', [child])
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
/*
|
|
72
|
+
* Each list: the parent reads its own row (so an empty answer below is the pin, not a broken fixture), and the
|
|
73
|
+
* child asking for the parent's with `inherited: Only` reads nothing.
|
|
74
|
+
*/
|
|
75
|
+
const LISTS: [string, () => Promise<any>, (params: any, context: any) => Promise<any>][] = [
|
|
76
|
+
[
|
|
77
|
+
'auth providers',
|
|
78
|
+
() => getRepository(AuthProvider).save({ domain: parent, type: 'keycloak', clientSecret: 'parent-secret' }),
|
|
79
|
+
(params, context) => new AuthProviderQuery().authProviders(params, context)
|
|
80
|
+
],
|
|
81
|
+
[
|
|
82
|
+
'appliances',
|
|
83
|
+
() => getRepository(Appliance).save({ domain: parent, name: 'gate', serialNo: 'gate-SN', brand: 'Acme', model: 'X1' }),
|
|
84
|
+
(params, context) => new ApplianceQuery().appliances(params, context)
|
|
85
|
+
],
|
|
86
|
+
[
|
|
87
|
+
'edges',
|
|
88
|
+
() => getRepository(Appliance).save({ domain: parent, name: 'edge', serialNo: 'edge-SN', brand: 'Acme', model: 'X1' }),
|
|
89
|
+
(params, context) => new ApplianceQuery().edges(params, context)
|
|
90
|
+
],
|
|
91
|
+
[
|
|
92
|
+
'applications',
|
|
93
|
+
() =>
|
|
94
|
+
getRepository(Application).save({
|
|
95
|
+
domain: parent,
|
|
96
|
+
name: 'billing',
|
|
97
|
+
email: 'billing@acme.z',
|
|
98
|
+
url: 'https://billing.example.com',
|
|
99
|
+
redirectUrl: 'https://billing.example.com/callback',
|
|
100
|
+
status: 0,
|
|
101
|
+
type: 'OTHERS',
|
|
102
|
+
appSecret: 'parent-app-secret'
|
|
103
|
+
}),
|
|
104
|
+
(params, context) => new ApplicationQuery().applications(params, context)
|
|
105
|
+
],
|
|
106
|
+
[
|
|
107
|
+
'invitations',
|
|
108
|
+
() =>
|
|
109
|
+
getRepository(Invitation).save({
|
|
110
|
+
email: 'lee@acme.z',
|
|
111
|
+
domain: parent,
|
|
112
|
+
status: InvitationStatus.PENDING,
|
|
113
|
+
token: 'parent-token',
|
|
114
|
+
expiresAt: new Date(Date.now() + 3600_000)
|
|
115
|
+
}),
|
|
116
|
+
(params, context) => new InvitationQuery().invitations(params, context)
|
|
117
|
+
],
|
|
118
|
+
[
|
|
119
|
+
'partners',
|
|
120
|
+
() => getRepository(Partner).save({ domain: parent, partnerDomain, requestedAt: new Date() }),
|
|
121
|
+
(params, context) => new PartnerQuery().partners(params, context)
|
|
122
|
+
],
|
|
123
|
+
[
|
|
124
|
+
'login histories',
|
|
125
|
+
() => LoginHistory.stamp(parent, parentUser, '10.0.0.1'),
|
|
126
|
+
(params, context) => new LoginHistoryQuery().loginHistories(params, context)
|
|
127
|
+
]
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
describe.each(LISTS)('%s', (_name, seed, list) => {
|
|
131
|
+
it("★ the parent reads its own row - the fixture is real", async () => {
|
|
132
|
+
await seed()
|
|
133
|
+
const answer = await list({}, from(parent, parentUser))
|
|
134
|
+
expect(answer.items).toHaveLength(1)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it("★ a child domain asking for the parent's with inherited: Only reads nothing", async () => {
|
|
138
|
+
await seed()
|
|
139
|
+
const answer = await list(ONLY, from(child, childUser))
|
|
140
|
+
expect(answer.items).toEqual([])
|
|
141
|
+
expect(answer.total).toBe(0)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it("a child domain asking with inherited: Include reads nothing of the parent's", async () => {
|
|
145
|
+
await seed()
|
|
146
|
+
const answer = await list({ inherited: 'Include' }, from(child, childUser))
|
|
147
|
+
expect(answer.items).toEqual([])
|
|
148
|
+
})
|
|
149
|
+
})
|
package/translations/en.json
CHANGED
|
@@ -249,5 +249,6 @@
|
|
|
249
249
|
"access-request.notify.denied.title": "access request denied",
|
|
250
250
|
"access-request.notify.denied.body": "{privilege}: {reason}",
|
|
251
251
|
"error.access-request-needs-reason": "write why you need it.",
|
|
252
|
-
"error.access-request-same-reason-as-denied": "this was denied lately for the same reason ({reason}). write what has changed."
|
|
252
|
+
"error.access-request-same-reason-as-denied": "this was denied lately for the same reason ({reason}). write what has changed.",
|
|
253
|
+
"error.role-belongs-to-parent": "{roles}: a role of the parent domain. only someone who decides roles there can give or take it."
|
|
253
254
|
}
|
package/translations/ja.json
CHANGED
|
@@ -249,5 +249,6 @@
|
|
|
249
249
|
"access-request.notify.denied.title": "権限リクエストが拒否されました",
|
|
250
250
|
"access-request.notify.denied.body": "{privilege}: {reason}",
|
|
251
251
|
"error.access-request-needs-reason": "必要な理由を書いてください。",
|
|
252
|
-
"error.access-request-same-reason-as-denied": "同じ理由で最近拒否されました({reason})。何が変わったかを書いてください。"
|
|
252
|
+
"error.access-request-same-reason-as-denied": "同じ理由で最近拒否されました({reason})。何が変わったかを書いてください。",
|
|
253
|
+
"error.role-belongs-to-parent": "{roles}: 上位$t(term.domain)のロールです。その$t(term.domain)で権限を決める人だけが付与・解除できます。"
|
|
253
254
|
}
|