OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-orgs commits / 233ce13

checkAccess: add a `read` permission level
The grant model could express who may WRITE a resource but had no way to say who may merely READ it. `permissionLevel` started at write: case 'owner': 3 case 'moderator': 2 case 'write': 1 default: 0 so 'read' fell to `default: 0`, and checkAccess's `if (required === 0) return false` made checkAccess({ requiredPermission: 'read' }) return false UNCONDITIONALLY — no grant of any strength could satisfy it. That is the missing dimension behind pear-git-v3's unbuilt repo-visibility model (spec/SYSTEM.md: "❌ Repo visibility model (public/private)"). Every layer of that stack gates writes and leaves reads open — the transports say so outright ("reads (git-upload-pack) stay open"), hyper-identity defaults every user to resources:[{'*': read}], and this grant model had no read level at all. SYSTEM.md names hyper-orgs as THE access control layer ("do not duplicate"), so read belongs here rather than in a parallel mechanism. Ladder is now read 1 < write 2 < moderator 3 < owner 4, so higher permissions imply read. Renumbering is safe: the values are compared only against each other, never persisted — grants store the permission STRING, and the only callers are the two comparisons inside checkAccess(). Unknown or absent permissions still map to 0 and are still rejected, so a typo'd requirement grants nothing even to an owner. `read` added to the three validateOp allow-lists (create-grant, create-invite, create-access-request). FORWARD COMPATIBILITY (checked, not assumed): validateOp runs at APPEND time (hyper-rooms room.js:185 `append()`), NOT inside the autobase apply — and apply stores `permission: op.permission` verbatim without validating. So a peer running older code that receives a `read` grant applies it cleanly, then its own permissionLevel('read') → 0 means the grant simply never satisfies anything. Graceful degradation, no apply divergence, no crash. Verified: hyper-orgs 7/7 test files (basic 24/24, 52 asserts) including 5 new cases — read satisfies read, higher levels imply read, read does NOT imply write/moderator/owner, unknown requirement grants nothing, and read via a group grant. Consumer pear-git-v3: read-old-orgs, repo-request-writer-auth, anonymous-rpc-gate, admin-grant-permission and basic all pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
233ce13513889b4ae712dea933bf7d19cfe6e5fd
by plankatron <fleeky@gmail.com> · 2026-07-31T16:56:32.000Z

Changed files (3) +127 -9

lib/hyper-orgs.js +21 -3
……@@ -475,11 +475,29 @@ export class HyperOrgs {
475475 // that owns the resource. checkAccess resolves resource→org, then
476476 // reads grants from that one Room.
477477
478 /**
479 * Ordinal for a permission. Higher satisfies lower.
480 *
481 * `read` is the lowest real level. Before it existed the ladder started at
482 * `write`, so `checkAccess({ requiredPermission: 'read' })` fell through to
483 * `default: 0` and the guard below returned false UNCONDITIONALLY — the
484 * grant model could express who may WRITE a resource but had no way to say
485 * who may merely READ it. That gap is why repo visibility (public/private)
486 * is unbuilt: every layer of the stack gates writes and leaves reads open.
487 *
488 * Unknown/absent permissions still map to 0 and are still rejected, so a
489 * typo does not silently grant anything.
490 *
491 * Numbering shifted (read 1 … owner 4). Safe: these values are only ever
492 * compared against each other, never persisted — grants store the permission
493 * STRING, and the only callers are the comparisons in checkAccess().
494 */
478495 static permissionLevel (perm) {
479496 switch (perm) {
480 case 'owner': return 3
481 case 'moderator': return 2
482 case 'write': return 1
497 case 'owner': return 4
498 case 'moderator': return 3
499 case 'write': return 2
500 case 'read': return 1
483501 default: return 0
484502 }
485503 }
lib/schema.js +6 -6
……@@ -103,8 +103,8 @@ export const orgsSchema = jsonSchema({
103103 if (op.grantType === 'resource' && !op.resourceName) {
104104 throw new Error('create-grant with grantType=resource requires resourceName')
105105 }
106 if (!op.permission || !['write', 'moderator', 'owner'].includes(op.permission)) {
107 throw new Error('create-grant requires permission (write|moderator|owner)')
106 if (!op.permission || !['read', 'write', 'moderator', 'owner'].includes(op.permission)) {
107 throw new Error('create-grant requires permission (read|write|moderator|owner)')
108108 }
109109 break
110110 case 'revoke-grant':
……@@ -122,8 +122,8 @@ export const orgsSchema = jsonSchema({
122122 if (op.inviteType === 'resource' && !op.targetResource) {
123123 throw new Error('create-invite with inviteType=resource requires targetResource')
124124 }
125 if (!op.permission || !['write', 'moderator', 'owner'].includes(op.permission)) {
126 throw new Error('create-invite requires permission (write|moderator|owner)')
125 if (!op.permission || !['read', 'write', 'moderator', 'owner'].includes(op.permission)) {
126 throw new Error('create-invite requires permission (read|write|moderator|owner)')
127127 }
128128 break
129129 case 'consume-invite':
……@@ -153,8 +153,8 @@ export const orgsSchema = jsonSchema({
153153 if (op.requestType === 'resource' && !op.resourceName) {
154154 throw new Error('create-access-request with requestType=resource requires resourceName')
155155 }
156 if (!op.permission || !['write', 'moderator', 'owner'].includes(op.permission)) {
157 throw new Error('create-access-request requires permission (write|moderator|owner)')
156 if (!op.permission || !['read', 'write', 'moderator', 'owner'].includes(op.permission)) {
157 throw new Error('create-access-request requires permission (read|write|moderator|owner)')
158158 }
159159 break
160160 case 'approve-access-request':
test/basic.test.js +100 -0
……@@ -277,6 +277,106 @@ test('checkAccess — denied when no grants', async (t) => {
277277 t.is(denied, false)
278278})
279279
280// `read` is the lowest real level. Before it existed, checkAccess with
281// requiredPermission:'read' hit `default: 0` and returned false no matter what
282// was granted — the model could say who may WRITE but not who may READ, which
283// is why repo visibility was unbuildable.
284
285test('checkAccess — a read grant satisfies a read requirement', async (t) => {
286 const orgs = await makeOrgs(t, 'test-22r')
287 await orgs.create('team', 'group')
288 await orgs.addResource('team', 'repo-a')
289
290 await orgs.createGrant({
291 subjectType: 'user',
292 subjectId: 'reader',
293 grantType: 'resource',
294 resourceName: 'repo-a',
295 permission: 'read'
296 })
297
298 t.is(await orgs.checkAccess({
299 userId: 'reader', resourceName: 'repo-a', requiredPermission: 'read'
300 }), true, 'read grant satisfies read')
301
302 t.is(await orgs.checkAccess({
303 userId: 'reader', resourceName: 'repo-a', requiredPermission: 'write'
304 }), false, 'read does NOT imply write')
305})
306
307test('checkAccess — higher permissions imply read', async (t) => {
308 const orgs = await makeOrgs(t, 'test-22h')
309 await orgs.create('team', 'group')
310 await orgs.addResource('team', 'repo-a')
311
312 for (const [who, perm] of [['w', 'write'], ['m', 'moderator'], ['o', 'owner']]) {
313 await orgs.createGrant({
314 subjectType: 'user',
315 subjectId: who,
316 grantType: 'resource',
317 resourceName: 'repo-a',
318 permission: perm
319 })
320 t.is(await orgs.checkAccess({
321 userId: who, resourceName: 'repo-a', requiredPermission: 'read'
322 }), true, perm + ' implies read')
323 }
324})
325
326test('checkAccess — a read grant does NOT satisfy moderator or owner', async (t) => {
327 const orgs = await makeOrgs(t, 'test-22n')
328 await orgs.create('team', 'group')
329 await orgs.addResource('team', 'repo-a')
330
331 await orgs.createGrant({
332 subjectType: 'user',
333 subjectId: 'reader',
334 grantType: 'resource',
335 resourceName: 'repo-a',
336 permission: 'read'
337 })
338
339 t.is(await orgs.checkAccess({ userId: 'reader', resourceName: 'repo-a', requiredPermission: 'moderator' }), false)
340 t.is(await orgs.checkAccess({ userId: 'reader', resourceName: 'repo-a', requiredPermission: 'owner' }), false)
341})
342
343test('checkAccess — an UNKNOWN permission still grants nothing', async (t) => {
344 const orgs = await makeOrgs(t, 'test-22u')
345 await orgs.create('team', 'group')
346 await orgs.addResource('team', 'repo-a')
347
348 await orgs.createGrant({
349 subjectType: 'user',
350 subjectId: 'alice',
351 grantType: 'resource',
352 resourceName: 'repo-a',
353 permission: 'owner'
354 })
355
356 // A typo'd requirement must not fall through to "allowed".
357 t.is(await orgs.checkAccess({
358 userId: 'alice', resourceName: 'repo-a', requiredPermission: 'reed'
359 }), false, 'unknown requirement rejected even for an owner')
360})
361
362test('checkAccess — a read grant works via a GROUP grant too', async (t) => {
363 const orgs = await makeOrgs(t, 'test-22g')
364 await orgs.create('team', 'group')
365 await orgs.addResource('team', 'repo-a')
366
367 await orgs.createGrant({
368 subjectType: 'user',
369 subjectId: 'alice',
370 grantType: 'group',
371 groupName: 'team',
372 permission: 'read'
373 })
374
375 t.is(await orgs.checkAccess({
376 userId: 'alice', resourceName: 'repo-a', requiredPermission: 'read'
377 }), true, 'org-wide read reaches the org\'s resources')
378})
379
280380test('checkAccess — group grant allows resource access', async (t) => {
281381 const orgs = await makeOrgs(t, 'test-23')
282382