Skip to content

Commit 8f560a9

Browse files
committed
Add testable identity permissions and visibility contract - PR_26152_066-identity-permissions-contract-tests
1 parent 0b5aaea commit 8f560a9

4 files changed

Lines changed: 729 additions & 0 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Identity Permissions Contract Tests Validation
2+
3+
PR: PR_26152_066-identity-permissions-contract-tests
4+
5+
Date: 2026-06-02
6+
7+
## Scope
8+
9+
Added a reusable identity/roles/permissions/visibility contract and targeted validation fixtures/tests.
10+
11+
No database implementation, authentication implementation, UI/page changes, CSS changes, or dependency changes were made.
12+
13+
## Files Validated
14+
15+
- `src/shared/contracts/identityPermissionsContract.js`
16+
- `tests/fixtures/identity-permissions/permission-scenarios.json`
17+
- `tests/shared/IdentityPermissionsContract.test.mjs`
18+
19+
## Validation Lanes
20+
21+
- Lanes executed: contract - identity roles, permissions, visibility, ownership, scoped grants, and fixture scenarios.
22+
- Lanes skipped: runtime, integration, engine, samples, recovery/UAT - this PR does not change runtime behavior, handoff contracts, engine code, samples, or UAT surfaces.
23+
- Samples decision: SKIP because this PR does not touch samples or sample JSON.
24+
- Playwright impacted: No. This PR adds contract data and targeted node tests only.
25+
26+
## Commands
27+
28+
```text
29+
node ./scripts/run-node-test-files.mjs tests/shared/IdentityPermissionsContract.test.mjs
30+
```
31+
32+
Result:
33+
34+
```text
35+
PASS tests/shared/IdentityPermissionsContract.test.mjs
36+
37+
1/1 targeted node test file(s) passed.
38+
```
39+
40+
```text
41+
git diff --check -- src/shared/contracts/identityPermissionsContract.js tests/shared/IdentityPermissionsContract.test.mjs tests/fixtures/identity-permissions/permission-scenarios.json
42+
```
43+
44+
Result:
45+
46+
```text
47+
PASS - no whitespace errors reported.
48+
```
49+
50+
## Contract Coverage
51+
52+
- Objects require owner: PASS.
53+
- Shareable objects require visibility: PASS.
54+
- Editable objects require permissions: PASS.
55+
- Guest cannot edit public content or view private content: PASS.
56+
- Owner has full object control for owned object permissions: PASS.
57+
- Admin, moderator, and reviewer permissions/scopes are distinct: PASS.
58+
- Valid permission fixtures pass: PASS.
59+
- Invalid permission fixtures fail with expected error codes: PASS.
60+
61+
## Scope Guard
62+
63+
- No database files changed.
64+
- No authentication files changed.
65+
- No HTML/page files changed.
66+
- No CSS files changed.
67+
- No samples tests were run.
68+
- No repo-wide tests were run.
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
/*
2+
Toolbox Aid
3+
David Quesenberry
4+
06/02/2026
5+
identityPermissionsContract.js
6+
*/
7+
export const IDENTITY_PERMISSIONS_CONTRACT_ID = "gamefoundrystudio.identity.permissions";
8+
export const IDENTITY_PERMISSIONS_CONTRACT_VERSION = "1.0.0";
9+
10+
export const IDENTITY_ROLES = Object.freeze({
11+
OWNER: "owner",
12+
ADMIN: "admin",
13+
MODERATOR: "moderator",
14+
CREATOR: "creator",
15+
CONTRIBUTOR: "contributor",
16+
REVIEWER: "reviewer",
17+
PLAYER: "player",
18+
GUEST: "guest",
19+
});
20+
21+
export const IDENTITY_ROLE_LIST = Object.freeze([
22+
IDENTITY_ROLES.OWNER,
23+
IDENTITY_ROLES.ADMIN,
24+
IDENTITY_ROLES.MODERATOR,
25+
IDENTITY_ROLES.CREATOR,
26+
IDENTITY_ROLES.CONTRIBUTOR,
27+
IDENTITY_ROLES.REVIEWER,
28+
IDENTITY_ROLES.PLAYER,
29+
IDENTITY_ROLES.GUEST,
30+
]);
31+
32+
export const IDENTITY_PERMISSIONS = Object.freeze({
33+
VIEW: "view",
34+
CREATE: "create",
35+
EDIT: "edit",
36+
DELETE: "delete",
37+
SHARE: "share",
38+
PUBLISH: "publish",
39+
REVIEW: "review",
40+
APPROVE: "approve",
41+
MODERATE: "moderate",
42+
ADMINISTER: "administer",
43+
});
44+
45+
export const IDENTITY_PERMISSION_LIST = Object.freeze([
46+
IDENTITY_PERMISSIONS.VIEW,
47+
IDENTITY_PERMISSIONS.CREATE,
48+
IDENTITY_PERMISSIONS.EDIT,
49+
IDENTITY_PERMISSIONS.DELETE,
50+
IDENTITY_PERMISSIONS.SHARE,
51+
IDENTITY_PERMISSIONS.PUBLISH,
52+
IDENTITY_PERMISSIONS.REVIEW,
53+
IDENTITY_PERMISSIONS.APPROVE,
54+
IDENTITY_PERMISSIONS.MODERATE,
55+
IDENTITY_PERMISSIONS.ADMINISTER,
56+
]);
57+
58+
export const IDENTITY_VISIBILITY_STATES = Object.freeze({
59+
PRIVATE: "private",
60+
SHARED: "shared",
61+
PROJECT: "project",
62+
TEAM: "team",
63+
UNLISTED: "unlisted",
64+
PUBLIC: "public",
65+
MARKETPLACE: "marketplace",
66+
ADMIN_ONLY: "admin-only",
67+
});
68+
69+
export const IDENTITY_VISIBILITY_LIST = Object.freeze([
70+
IDENTITY_VISIBILITY_STATES.PRIVATE,
71+
IDENTITY_VISIBILITY_STATES.SHARED,
72+
IDENTITY_VISIBILITY_STATES.PROJECT,
73+
IDENTITY_VISIBILITY_STATES.TEAM,
74+
IDENTITY_VISIBILITY_STATES.UNLISTED,
75+
IDENTITY_VISIBILITY_STATES.PUBLIC,
76+
IDENTITY_VISIBILITY_STATES.MARKETPLACE,
77+
IDENTITY_VISIBILITY_STATES.ADMIN_ONLY,
78+
]);
79+
80+
export const IDENTITY_PERMISSION_SCOPES = Object.freeze({
81+
OWNED_OBJECT: "owned-object",
82+
USER: "user",
83+
PROJECT: "project",
84+
REVIEW: "review",
85+
COMMUNITY: "community",
86+
PUBLIC: "public",
87+
MARKETPLACE: "marketplace",
88+
PLATFORM: "platform",
89+
});
90+
91+
export const IDENTITY_OWNERSHIP_RULES = Object.freeze({
92+
DATABASE_OBJECTS_REQUIRE_OWNER: true,
93+
SHAREABLE_OBJECTS_REQUIRE_VISIBILITY: true,
94+
EDITABLE_OBJECTS_REQUIRE_PERMISSIONS: true,
95+
});
96+
97+
export const IDENTITY_OBJECT_CONTROL_PERMISSIONS = Object.freeze([
98+
IDENTITY_PERMISSIONS.VIEW,
99+
IDENTITY_PERMISSIONS.CREATE,
100+
IDENTITY_PERMISSIONS.EDIT,
101+
IDENTITY_PERMISSIONS.DELETE,
102+
IDENTITY_PERMISSIONS.SHARE,
103+
IDENTITY_PERMISSIONS.PUBLISH,
104+
IDENTITY_PERMISSIONS.REVIEW,
105+
IDENTITY_PERMISSIONS.APPROVE,
106+
]);
107+
108+
export const IDENTITY_ROLE_PERMISSION_GRANTS = Object.freeze({
109+
[IDENTITY_ROLES.OWNER]: IDENTITY_OBJECT_CONTROL_PERMISSIONS,
110+
[IDENTITY_ROLES.ADMIN]: Object.freeze([
111+
IDENTITY_PERMISSIONS.VIEW,
112+
IDENTITY_PERMISSIONS.ADMINISTER,
113+
]),
114+
[IDENTITY_ROLES.MODERATOR]: Object.freeze([
115+
IDENTITY_PERMISSIONS.VIEW,
116+
IDENTITY_PERMISSIONS.MODERATE,
117+
]),
118+
[IDENTITY_ROLES.CREATOR]: Object.freeze([
119+
IDENTITY_PERMISSIONS.VIEW,
120+
IDENTITY_PERMISSIONS.CREATE,
121+
IDENTITY_PERMISSIONS.EDIT,
122+
IDENTITY_PERMISSIONS.DELETE,
123+
IDENTITY_PERMISSIONS.SHARE,
124+
IDENTITY_PERMISSIONS.PUBLISH,
125+
]),
126+
[IDENTITY_ROLES.CONTRIBUTOR]: Object.freeze([
127+
IDENTITY_PERMISSIONS.VIEW,
128+
IDENTITY_PERMISSIONS.EDIT,
129+
]),
130+
[IDENTITY_ROLES.REVIEWER]: Object.freeze([
131+
IDENTITY_PERMISSIONS.VIEW,
132+
IDENTITY_PERMISSIONS.REVIEW,
133+
IDENTITY_PERMISSIONS.APPROVE,
134+
]),
135+
[IDENTITY_ROLES.PLAYER]: Object.freeze([
136+
IDENTITY_PERMISSIONS.VIEW,
137+
]),
138+
[IDENTITY_ROLES.GUEST]: Object.freeze([
139+
IDENTITY_PERMISSIONS.VIEW,
140+
]),
141+
});
142+
143+
export const IDENTITY_ROLE_SCOPE_GRANTS = Object.freeze({
144+
[IDENTITY_ROLES.ADMIN]: Object.freeze([
145+
IDENTITY_PERMISSION_SCOPES.PLATFORM,
146+
]),
147+
[IDENTITY_ROLES.MODERATOR]: Object.freeze([
148+
IDENTITY_PERMISSION_SCOPES.COMMUNITY,
149+
IDENTITY_PERMISSION_SCOPES.PUBLIC,
150+
IDENTITY_PERMISSION_SCOPES.MARKETPLACE,
151+
]),
152+
[IDENTITY_ROLES.REVIEWER]: Object.freeze([
153+
IDENTITY_PERMISSION_SCOPES.REVIEW,
154+
]),
155+
});
156+
157+
export const IDENTITY_PERMISSION_ERRORS = Object.freeze({
158+
OWNER_REQUIRED: "OWNER_REQUIRED",
159+
VISIBILITY_REQUIRED: "VISIBILITY_REQUIRED",
160+
VISIBILITY_INVALID: "VISIBILITY_INVALID",
161+
PERMISSIONS_REQUIRED: "PERMISSIONS_REQUIRED",
162+
PERMISSION_INVALID: "PERMISSION_INVALID",
163+
});
164+
165+
export function isIdentityRole(value) {
166+
return IDENTITY_ROLE_LIST.includes(value);
167+
}
168+
169+
export function isIdentityPermission(value) {
170+
return IDENTITY_PERMISSION_LIST.includes(value);
171+
}
172+
173+
export function isIdentityVisibility(value) {
174+
return IDENTITY_VISIBILITY_LIST.includes(value);
175+
}
176+
177+
export function validateIdentityPermissionObject(object) {
178+
const errors = [];
179+
180+
if (!hasNonEmptyString(object?.ownerId)) {
181+
errors.push(createContractError(
182+
IDENTITY_PERMISSION_ERRORS.OWNER_REQUIRED,
183+
"Database objects require ownerId.",
184+
"ownerId"
185+
));
186+
}
187+
188+
if (object?.shareable === true) {
189+
if (!hasNonEmptyString(object.visibility)) {
190+
errors.push(createContractError(
191+
IDENTITY_PERMISSION_ERRORS.VISIBILITY_REQUIRED,
192+
"Shareable objects require explicit visibility.",
193+
"visibility"
194+
));
195+
} else if (!isIdentityVisibility(object.visibility)) {
196+
errors.push(createContractError(
197+
IDENTITY_PERMISSION_ERRORS.VISIBILITY_INVALID,
198+
"Shareable object visibility must be an allowed identity visibility state.",
199+
"visibility"
200+
));
201+
}
202+
}
203+
204+
if (object?.editable === true) {
205+
if (!Array.isArray(object.permissions) || object.permissions.length === 0) {
206+
errors.push(createContractError(
207+
IDENTITY_PERMISSION_ERRORS.PERMISSIONS_REQUIRED,
208+
"Editable objects require explicit permissions.",
209+
"permissions"
210+
));
211+
} else {
212+
for (const permission of object.permissions) {
213+
if (!isIdentityPermission(permission)) {
214+
errors.push(createContractError(
215+
IDENTITY_PERMISSION_ERRORS.PERMISSION_INVALID,
216+
"Editable object permissions must use allowed identity permissions.",
217+
"permissions"
218+
));
219+
}
220+
}
221+
}
222+
}
223+
224+
return Object.freeze({
225+
valid: errors.length === 0,
226+
errors: Object.freeze(errors),
227+
});
228+
}
229+
230+
export function canActorPerformPermission({
231+
actorId,
232+
role,
233+
permission,
234+
object,
235+
scope,
236+
grantedScopes = [],
237+
} = {}) {
238+
if (!isIdentityRole(role) || !isIdentityPermission(permission)) {
239+
return false;
240+
}
241+
242+
if (role === IDENTITY_ROLES.GUEST) {
243+
return permission === IDENTITY_PERMISSIONS.VIEW
244+
&& object?.visibility === IDENTITY_VISIBILITY_STATES.PUBLIC;
245+
}
246+
247+
if (role === IDENTITY_ROLES.OWNER) {
248+
return hasNonEmptyString(actorId)
249+
&& actorId === object?.ownerId
250+
&& IDENTITY_OBJECT_CONTROL_PERMISSIONS.includes(permission);
251+
}
252+
253+
const permissions = IDENTITY_ROLE_PERMISSION_GRANTS[role] ?? [];
254+
if (!permissions.includes(permission)) {
255+
return false;
256+
}
257+
258+
if (role === IDENTITY_ROLES.CONTRIBUTOR || role === IDENTITY_ROLES.REVIEWER) {
259+
return hasGrantedScope(scope, grantedScopes);
260+
}
261+
262+
const roleScopes = IDENTITY_ROLE_SCOPE_GRANTS[role];
263+
if (roleScopes) {
264+
return roleScopes.includes(scope);
265+
}
266+
267+
return true;
268+
}
269+
270+
function hasNonEmptyString(value) {
271+
return typeof value === "string" && value.trim().length > 0;
272+
}
273+
274+
function hasGrantedScope(scope, grantedScopes) {
275+
return hasNonEmptyString(scope)
276+
&& Array.isArray(grantedScopes)
277+
&& grantedScopes.includes(scope);
278+
}
279+
280+
function createContractError(code, message, path) {
281+
return Object.freeze({ code, message, path });
282+
}

0 commit comments

Comments
 (0)