Granular access control: per-verb permissions, denials, and the _acl block - #1122

Merged
coodos merged 7 commits into
mainfrom
feat/acl-permissions
Aug 31, 2026
Merged

Granular access control: per-verb permissions, denials, and the _acl block#1122
coodos merged 7 commits into
mainfrom
feat/acl-permissions

Conversation

@coodos

@coodoscoodos commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Records in an eVault now carry their own access rules: who may read, add, change, or delete them, held inside the record so the policy travels with the data when it syncs.

This implements the list half of the access control design — grants, denials, and the decision order. The Resource Link Ontology half (resolving a platform's score and comparing it) is left as an injected seam, since the scoring side already exists elsewhere.

The problem

Platforms have no access controls between them. Any platform holding a valid Registry-issued token can reach anything that syncs to it, and the legacy acl array is all-or-nothing — there is no read-without-write except ["*"], which is what essentially every record in the system is written with today.

What is here

A policy engine (infrastructure/evault-core/src/core/acl/) — pure, no I/O:

  • Permissions as a bitmask: 0x01 READ, 0x02 CREATE, 0x04 UPDATE, 0x08 DELETE. 0x03 expresses add-only access. Reserved bits 4-7 are rejected on write and stripped on read; 0x00 counts as no grant rather than an empty one.
  • Most-specific-wins: a user grant beats a platform grant beats a group grant, with no union across specificity. Grants tied at the same specificity are unioned, so the outcome does not depend on storage order.
  • Denials always win, by eName (matching the party, the platform carrying its request, or a group it belongs to) or by a failing condition.
  • Decision order: denials, then the single most specific grant, then the require groups against default_perms. A named party never falls through from a grant to default_perms.

Persistence — the block is stored as an aclBlock JSON property on :MetaEnvelope, carried through every read site, both store paths, the update path, and the cross-eVault migration copy. An update that omits _acl preserves the stored policy rather than clearing it.

EnforcementVaultAccessGuard now takes the permission each operation needs; all 21 resolvers are mapped. The consequential change:

A record carrying an _acl block is decided by that block for every caller, including a platform holding a valid Registry token. Closing that bypass is the point of the model.

Delegated identity — a platform's token proves the platform, not which of its users a request is for. X-ON-BEHALF-OF: @<ename> carries that: the named user becomes the party, with the carrying platform recorded alongside it, so a user grant applies at user specificity while a platform grant still applies at its own.

It is the platform's assertion, not a proof — the eVault cannot verify it, so a platform can reach what a user was granted, including more than its own grant. That is the deliberate reading of the design's specificity rule, and a platform that can write to a vault can already act as its users in other ways. What it cannot do is escape a denial: denials match the carrying platform too.

Only @-prefixed eNames are accepted as parties. This also fixes a real trap: currentUser is derived from the JWT kid, which for a Registry platform token is the literal string entropy-key-1 — a signing-key id, not a party. It is no longer treated as an identity.

Reading the policy backMetaEnvelope._acl is exposed to anyone permitted to read the record, as typed output (AclBlock/AclGrant/AclDenials/AclCondition). It always reports the policy in force: a record carrying only a legacy array reports the block that array maps to, so clients see one shape regardless of how the record was written. The legacy array itself is still never returned.

Note this means a denial list is visible to permitted readers — it names the parties an owner excluded.

GraphQL inputAclBlockInput and friends, with _acl on MetaEnvelopeInput, BulkMetaEnvelopeInput, and UploadFileInput.

Nothing narrows until an owner sets a policy

Every platform writes acl: ["*"] today, so back-compat mattered more than purity here. Records with no _acl keep their exact current behaviour, token bypass included. Legacy arrays are read as: ["*"]default_perms 0x0F behind an always-passing group; ["@ename"] → a 0x0F grant to that eName. Binding documents (acl: [subject]) are unaffected.

Docs

  • W3DS Protocol/Access-Control.md — the protocol model, wire format, decision order, the delegation header, and reading policies back.
  • Post Platform Guide/access-control.md — the implementation guide: worked policy shapes, the perms table, header usage, and the failure modes most likely to bite (a grant is final; 0 means no grant, not "denied"; a platform token will not open a policied record; policies replace rather than merge).
  • Infrastructure/eVault.md rewritten — it previously documented the old model as a limitation with granular permissions "planned for future versions".
  • The bundled skills/w3ds reference carried the same stale claim and is corrected; it is symlinked into installed agent skills.

Deliberately not in scope

  • Group membership is not resolved. A grant or denial naming a group matches nobody. Fail-closed for grants, but fail-open for group denials — so group denials are not usable until a membership lookup is wired into principalFor.
  • No condition evaluator is connected. Conditions currently fail closed: a require group containing conditions cannot pass, and a deny condition always fires. Policies should use grants, denials.enames, and empty-group require until one lands.
  • eVault only. The Web3 Adapter hardcodes acl: ["*"] with no _acl parameter and does not send X-ON-BEHALF-OF, so records written through handleChange cannot carry a policy or a delegated identity yet — a direct GraphQL call is needed. Platforms do not evaluate policies themselves.
  • The binary wire format from §9 of the design has no consumer in this layer.

Deploying

No migration. Neo4j is schemaless and aclBlock is a new optional property, so existing nodes take the legacy path untouched. Nothing can write a policy through the adapter yet, so the change is inert on deploy.

One caution: treat this as forward-only once policies start being written. Rolling back means old code ignoring aclBlock and reading the acl array, which platforms set to ["*"] — a locked-down record would become world-open again.

Testing

30 engine tests written from the design's normative examples, plus 19 guard integration tests against real Neo4j covering the token-bypass closure, specificity, deny-over-grant, default_perms, fail-closed conditions, delegated identity and its denial escape-hatch, rejection of non-eName identities, and legacy records staying unchanged. Full suite: 213/213 across 18 files. Typecheck clean; docs build passes with onBrokenLinks: 'throw'.

https://claude.ai/code/session_01UpwygDu2cizLp12tvvKqVZ

@coderabbitai

coderabbitaiBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 43fef62c-f09c-48a9-ae99-74e38cd7f1dc


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coodos
coodos merged commit 8079ee6 into mainAug 31, 2026
6 checks passed
@coodos
coodos deleted the feat/acl-permissions branch August 31, 2026 09:41
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@coodos
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Granular access control: per-verb permissions, denials, and the _acl block - #1122

Merged
coodos merged 7 commits into
mainfrom
feat/acl-permissions
Aug 31, 2026
Merged

Granular access control: per-verb permissions, denials, and the _acl block#1122
coodos merged 7 commits into
mainfrom
feat/acl-permissions

Conversation

@coodos

@coodoscoodos commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Records in an eVault now carry their own access rules: who may read, add, change, or delete them, held inside the record so the policy travels with the data when it syncs.

This implements the list half of the access control design — grants, denials, and the decision order. The Resource Link Ontology half (resolving a platform's score and comparing it) is left as an injected seam, since the scoring side already exists elsewhere.

The problem

Platforms have no access controls between them. Any platform holding a valid Registry-issued token can reach anything that syncs to it, and the legacy acl array is all-or-nothing — there is no read-without-write except ["*"], which is what essentially every record in the system is written with today.

What is here

A policy engine (infrastructure/evault-core/src/core/acl/) — pure, no I/O:

  • Permissions as a bitmask: 0x01 READ, 0x02 CREATE, 0x04 UPDATE, 0x08 DELETE. 0x03 expresses add-only access. Reserved bits 4-7 are rejected on write and stripped on read; 0x00 counts as no grant rather than an empty one.
  • Most-specific-wins: a user grant beats a platform grant beats a group grant, with no union across specificity. Grants tied at the same specificity are unioned, so the outcome does not depend on storage order.
  • Denials always win, by eName (matching the party, the platform carrying its request, or a group it belongs to) or by a failing condition.
  • Decision order: denials, then the single most specific grant, then the require groups against default_perms. A named party never falls through from a grant to default_perms.

Persistence — the block is stored as an aclBlock JSON property on :MetaEnvelope, carried through every read site, both store paths, the update path, and the cross-eVault migration copy. An update that omits _acl preserves the stored policy rather than clearing it.

EnforcementVaultAccessGuard now takes the permission each operation needs; all 21 resolvers are mapped. The consequential change:

A record carrying an _acl block is decided by that block for every caller, including a platform holding a valid Registry token. Closing that bypass is the point of the model.

Delegated identity — a platform's token proves the platform, not which of its users a request is for. X-ON-BEHALF-OF: @<ename> carries that: the named user becomes the party, with the carrying platform recorded alongside it, so a user grant applies at user specificity while a platform grant still applies at its own.

It is the platform's assertion, not a proof — the eVault cannot verify it, so a platform can reach what a user was granted, including more than its own grant. That is the deliberate reading of the design's specificity rule, and a platform that can write to a vault can already act as its users in other ways. What it cannot do is escape a denial: denials match the carrying platform too.

Only @-prefixed eNames are accepted as parties. This also fixes a real trap: currentUser is derived from the JWT kid, which for a Registry platform token is the literal string entropy-key-1 — a signing-key id, not a party. It is no longer treated as an identity.

Reading the policy backMetaEnvelope._acl is exposed to anyone permitted to read the record, as typed output (AclBlock/AclGrant/AclDenials/AclCondition). It always reports the policy in force: a record carrying only a legacy array reports the block that array maps to, so clients see one shape regardless of how the record was written. The legacy array itself is still never returned.

Note this means a denial list is visible to permitted readers — it names the parties an owner excluded.

GraphQL inputAclBlockInput and friends, with _acl on MetaEnvelopeInput, BulkMetaEnvelopeInput, and UploadFileInput.

Nothing narrows until an owner sets a policy

Every platform writes acl: ["*"] today, so back-compat mattered more than purity here. Records with no _acl keep their exact current behaviour, token bypass included. Legacy arrays are read as: ["*"]default_perms 0x0F behind an always-passing group; ["@ename"] → a 0x0F grant to that eName. Binding documents (acl: [subject]) are unaffected.

Docs

  • W3DS Protocol/Access-Control.md — the protocol model, wire format, decision order, the delegation header, and reading policies back.
  • Post Platform Guide/access-control.md — the implementation guide: worked policy shapes, the perms table, header usage, and the failure modes most likely to bite (a grant is final; 0 means no grant, not "denied"; a platform token will not open a policied record; policies replace rather than merge).
  • Infrastructure/eVault.md rewritten — it previously documented the old model as a limitation with granular permissions "planned for future versions".
  • The bundled skills/w3ds reference carried the same stale claim and is corrected; it is symlinked into installed agent skills.

Deliberately not in scope

  • Group membership is not resolved. A grant or denial naming a group matches nobody. Fail-closed for grants, but fail-open for group denials — so group denials are not usable until a membership lookup is wired into principalFor.
  • No condition evaluator is connected. Conditions currently fail closed: a require group containing conditions cannot pass, and a deny condition always fires. Policies should use grants, denials.enames, and empty-group require until one lands.
  • eVault only. The Web3 Adapter hardcodes acl: ["*"] with no _acl parameter and does not send X-ON-BEHALF-OF, so records written through handleChange cannot carry a policy or a delegated identity yet — a direct GraphQL call is needed. Platforms do not evaluate policies themselves.
  • The binary wire format from §9 of the design has no consumer in this layer.

Deploying

No migration. Neo4j is schemaless and aclBlock is a new optional property, so existing nodes take the legacy path untouched. Nothing can write a policy through the adapter yet, so the change is inert on deploy.

One caution: treat this as forward-only once policies start being written. Rolling back means old code ignoring aclBlock and reading the acl array, which platforms set to ["*"] — a locked-down record would become world-open again.

Testing

30 engine tests written from the design's normative examples, plus 19 guard integration tests against real Neo4j covering the token-bypass closure, specificity, deny-over-grant, default_perms, fail-closed conditions, delegated identity and its denial escape-hatch, rejection of non-eName identities, and legacy records staying unchanged. Full suite: 213/213 across 18 files. Typecheck clean; docs build passes with onBrokenLinks: 'throw'.

https://claude.ai/code/session_01UpwygDu2cizLp12tvvKqVZ

@coderabbitai

coderabbitaiBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 43fef62c-f09c-48a9-ae99-74e38cd7f1dc


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coodos
coodos merged commit 8079ee6 into mainAug 31, 2026
6 checks passed
@coodos
coodos deleted the feat/acl-permissions branch August 31, 2026 09:41
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@coodos
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Granular access control: per-verb permissions, denials, and the _acl block - #1122

Merged
coodos merged 7 commits into
mainfrom
feat/acl-permissions
Aug 31, 2026
Merged

Granular access control: per-verb permissions, denials, and the _acl block#1122
coodos merged 7 commits into
mainfrom
feat/acl-permissions

Conversation

@coodos

@coodoscoodos commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Records in an eVault now carry their own access rules: who may read, add, change, or delete them, held inside the record so the policy travels with the data when it syncs.

This implements the list half of the access control design — grants, denials, and the decision order. The Resource Link Ontology half (resolving a platform's score and comparing it) is left as an injected seam, since the scoring side already exists elsewhere.

The problem

Platforms have no access controls between them. Any platform holding a valid Registry-issued token can reach anything that syncs to it, and the legacy acl array is all-or-nothing — there is no read-without-write except ["*"], which is what essentially every record in the system is written with today.

What is here

A policy engine (infrastructure/evault-core/src/core/acl/) — pure, no I/O:

  • Permissions as a bitmask: 0x01 READ, 0x02 CREATE, 0x04 UPDATE, 0x08 DELETE. 0x03 expresses add-only access. Reserved bits 4-7 are rejected on write and stripped on read; 0x00 counts as no grant rather than an empty one.
  • Most-specific-wins: a user grant beats a platform grant beats a group grant, with no union across specificity. Grants tied at the same specificity are unioned, so the outcome does not depend on storage order.
  • Denials always win, by eName (matching the party, the platform carrying its request, or a group it belongs to) or by a failing condition.
  • Decision order: denials, then the single most specific grant, then the require groups against default_perms. A named party never falls through from a grant to default_perms.

Persistence — the block is stored as an aclBlock JSON property on :MetaEnvelope, carried through every read site, both store paths, the update path, and the cross-eVault migration copy. An update that omits _acl preserves the stored policy rather than clearing it.

EnforcementVaultAccessGuard now takes the permission each operation needs; all 21 resolvers are mapped. The consequential change:

A record carrying an _acl block is decided by that block for every caller, including a platform holding a valid Registry token. Closing that bypass is the point of the model.

Delegated identity — a platform's token proves the platform, not which of its users a request is for. X-ON-BEHALF-OF: @<ename> carries that: the named user becomes the party, with the carrying platform recorded alongside it, so a user grant applies at user specificity while a platform grant still applies at its own.

It is the platform's assertion, not a proof — the eVault cannot verify it, so a platform can reach what a user was granted, including more than its own grant. That is the deliberate reading of the design's specificity rule, and a platform that can write to a vault can already act as its users in other ways. What it cannot do is escape a denial: denials match the carrying platform too.

Only @-prefixed eNames are accepted as parties. This also fixes a real trap: currentUser is derived from the JWT kid, which for a Registry platform token is the literal string entropy-key-1 — a signing-key id, not a party. It is no longer treated as an identity.

Reading the policy backMetaEnvelope._acl is exposed to anyone permitted to read the record, as typed output (AclBlock/AclGrant/AclDenials/AclCondition). It always reports the policy in force: a record carrying only a legacy array reports the block that array maps to, so clients see one shape regardless of how the record was written. The legacy array itself is still never returned.

Note this means a denial list is visible to permitted readers — it names the parties an owner excluded.

GraphQL inputAclBlockInput and friends, with _acl on MetaEnvelopeInput, BulkMetaEnvelopeInput, and UploadFileInput.

Nothing narrows until an owner sets a policy

Every platform writes acl: ["*"] today, so back-compat mattered more than purity here. Records with no _acl keep their exact current behaviour, token bypass included. Legacy arrays are read as: ["*"]default_perms 0x0F behind an always-passing group; ["@ename"] → a 0x0F grant to that eName. Binding documents (acl: [subject]) are unaffected.

Docs

  • W3DS Protocol/Access-Control.md — the protocol model, wire format, decision order, the delegation header, and reading policies back.
  • Post Platform Guide/access-control.md — the implementation guide: worked policy shapes, the perms table, header usage, and the failure modes most likely to bite (a grant is final; 0 means no grant, not "denied"; a platform token will not open a policied record; policies replace rather than merge).
  • Infrastructure/eVault.md rewritten — it previously documented the old model as a limitation with granular permissions "planned for future versions".
  • The bundled skills/w3ds reference carried the same stale claim and is corrected; it is symlinked into installed agent skills.

Deliberately not in scope

  • Group membership is not resolved. A grant or denial naming a group matches nobody. Fail-closed for grants, but fail-open for group denials — so group denials are not usable until a membership lookup is wired into principalFor.
  • No condition evaluator is connected. Conditions currently fail closed: a require group containing conditions cannot pass, and a deny condition always fires. Policies should use grants, denials.enames, and empty-group require until one lands.
  • eVault only. The Web3 Adapter hardcodes acl: ["*"] with no _acl parameter and does not send X-ON-BEHALF-OF, so records written through handleChange cannot carry a policy or a delegated identity yet — a direct GraphQL call is needed. Platforms do not evaluate policies themselves.
  • The binary wire format from §9 of the design has no consumer in this layer.

Deploying

No migration. Neo4j is schemaless and aclBlock is a new optional property, so existing nodes take the legacy path untouched. Nothing can write a policy through the adapter yet, so the change is inert on deploy.

One caution: treat this as forward-only once policies start being written. Rolling back means old code ignoring aclBlock and reading the acl array, which platforms set to ["*"] — a locked-down record would become world-open again.

Testing

30 engine tests written from the design's normative examples, plus 19 guard integration tests against real Neo4j covering the token-bypass closure, specificity, deny-over-grant, default_perms, fail-closed conditions, delegated identity and its denial escape-hatch, rejection of non-eName identities, and legacy records staying unchanged. Full suite: 213/213 across 18 files. Typecheck clean; docs build passes with onBrokenLinks: 'throw'.

https://claude.ai/code/session_01UpwygDu2cizLp12tvvKqVZ

@coderabbitai

coderabbitaiBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 43fef62c-f09c-48a9-ae99-74e38cd7f1dc


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coodos
coodos merged commit 8079ee6 into mainAug 31, 2026
6 checks passed
@coodos
coodos deleted the feat/acl-permissions branch August 31, 2026 09:41
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@coodos
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Granular access control: per-verb permissions, denials, and the _acl block - #1122

Merged
coodos merged 7 commits into
mainfrom
feat/acl-permissions
Aug 31, 2026
Merged

Granular access control: per-verb permissions, denials, and the _acl block#1122
coodos merged 7 commits into
mainfrom
feat/acl-permissions

Conversation

@coodos

@coodoscoodos commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Records in an eVault now carry their own access rules: who may read, add, change, or delete them, held inside the record so the policy travels with the data when it syncs.

This implements the list half of the access control design — grants, denials, and the decision order. The Resource Link Ontology half (resolving a platform's score and comparing it) is left as an injected seam, since the scoring side already exists elsewhere.

The problem

Platforms have no access controls between them. Any platform holding a valid Registry-issued token can reach anything that syncs to it, and the legacy acl array is all-or-nothing — there is no read-without-write except ["*"], which is what essentially every record in the system is written with today.

What is here

A policy engine (infrastructure/evault-core/src/core/acl/) — pure, no I/O:

  • Permissions as a bitmask: 0x01 READ, 0x02 CREATE, 0x04 UPDATE, 0x08 DELETE. 0x03 expresses add-only access. Reserved bits 4-7 are rejected on write and stripped on read; 0x00 counts as no grant rather than an empty one.
  • Most-specific-wins: a user grant beats a platform grant beats a group grant, with no union across specificity. Grants tied at the same specificity are unioned, so the outcome does not depend on storage order.
  • Denials always win, by eName (matching the party, the platform carrying its request, or a group it belongs to) or by a failing condition.
  • Decision order: denials, then the single most specific grant, then the require groups against default_perms. A named party never falls through from a grant to default_perms.

Persistence — the block is stored as an aclBlock JSON property on :MetaEnvelope, carried through every read site, both store paths, the update path, and the cross-eVault migration copy. An update that omits _acl preserves the stored policy rather than clearing it.

EnforcementVaultAccessGuard now takes the permission each operation needs; all 21 resolvers are mapped. The consequential change:

A record carrying an _acl block is decided by that block for every caller, including a platform holding a valid Registry token. Closing that bypass is the point of the model.

Delegated identity — a platform's token proves the platform, not which of its users a request is for. X-ON-BEHALF-OF: @<ename> carries that: the named user becomes the party, with the carrying platform recorded alongside it, so a user grant applies at user specificity while a platform grant still applies at its own.

It is the platform's assertion, not a proof — the eVault cannot verify it, so a platform can reach what a user was granted, including more than its own grant. That is the deliberate reading of the design's specificity rule, and a platform that can write to a vault can already act as its users in other ways. What it cannot do is escape a denial: denials match the carrying platform too.

Only @-prefixed eNames are accepted as parties. This also fixes a real trap: currentUser is derived from the JWT kid, which for a Registry platform token is the literal string entropy-key-1 — a signing-key id, not a party. It is no longer treated as an identity.

Reading the policy backMetaEnvelope._acl is exposed to anyone permitted to read the record, as typed output (AclBlock/AclGrant/AclDenials/AclCondition). It always reports the policy in force: a record carrying only a legacy array reports the block that array maps to, so clients see one shape regardless of how the record was written. The legacy array itself is still never returned.

Note this means a denial list is visible to permitted readers — it names the parties an owner excluded.

GraphQL inputAclBlockInput and friends, with _acl on MetaEnvelopeInput, BulkMetaEnvelopeInput, and UploadFileInput.

Nothing narrows until an owner sets a policy

Every platform writes acl: ["*"] today, so back-compat mattered more than purity here. Records with no _acl keep their exact current behaviour, token bypass included. Legacy arrays are read as: ["*"]default_perms 0x0F behind an always-passing group; ["@ename"] → a 0x0F grant to that eName. Binding documents (acl: [subject]) are unaffected.

Docs

  • W3DS Protocol/Access-Control.md — the protocol model, wire format, decision order, the delegation header, and reading policies back.
  • Post Platform Guide/access-control.md — the implementation guide: worked policy shapes, the perms table, header usage, and the failure modes most likely to bite (a grant is final; 0 means no grant, not "denied"; a platform token will not open a policied record; policies replace rather than merge).
  • Infrastructure/eVault.md rewritten — it previously documented the old model as a limitation with granular permissions "planned for future versions".
  • The bundled skills/w3ds reference carried the same stale claim and is corrected; it is symlinked into installed agent skills.

Deliberately not in scope

  • Group membership is not resolved. A grant or denial naming a group matches nobody. Fail-closed for grants, but fail-open for group denials — so group denials are not usable until a membership lookup is wired into principalFor.
  • No condition evaluator is connected. Conditions currently fail closed: a require group containing conditions cannot pass, and a deny condition always fires. Policies should use grants, denials.enames, and empty-group require until one lands.
  • eVault only. The Web3 Adapter hardcodes acl: ["*"] with no _acl parameter and does not send X-ON-BEHALF-OF, so records written through handleChange cannot carry a policy or a delegated identity yet — a direct GraphQL call is needed. Platforms do not evaluate policies themselves.
  • The binary wire format from §9 of the design has no consumer in this layer.

Deploying

No migration. Neo4j is schemaless and aclBlock is a new optional property, so existing nodes take the legacy path untouched. Nothing can write a policy through the adapter yet, so the change is inert on deploy.

One caution: treat this as forward-only once policies start being written. Rolling back means old code ignoring aclBlock and reading the acl array, which platforms set to ["*"] — a locked-down record would become world-open again.

Testing

30 engine tests written from the design's normative examples, plus 19 guard integration tests against real Neo4j covering the token-bypass closure, specificity, deny-over-grant, default_perms, fail-closed conditions, delegated identity and its denial escape-hatch, rejection of non-eName identities, and legacy records staying unchanged. Full suite: 213/213 across 18 files. Typecheck clean; docs build passes with onBrokenLinks: 'throw'.

https://claude.ai/code/session_01UpwygDu2cizLp12tvvKqVZ

@coderabbitai

coderabbitaiBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 43fef62c-f09c-48a9-ae99-74e38cd7f1dc


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coodos
coodos merged commit 8079ee6 into mainAug 31, 2026
6 checks passed
@coodos
coodos deleted the feat/acl-permissions branch August 31, 2026 09:41
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@coodos
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Granular access control: per-verb permissions, denials, and the _acl block - #1122

Merged
coodos merged 7 commits into
mainfrom
feat/acl-permissions
Aug 31, 2026
Merged

Granular access control: per-verb permissions, denials, and the _acl block#1122
coodos merged 7 commits into
mainfrom
feat/acl-permissions

Conversation

@coodos

@coodoscoodos commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Records in an eVault now carry their own access rules: who may read, add, change, or delete them, held inside the record so the policy travels with the data when it syncs.

This implements the list half of the access control design — grants, denials, and the decision order. The Resource Link Ontology half (resolving a platform's score and comparing it) is left as an injected seam, since the scoring side already exists elsewhere.

The problem

Platforms have no access controls between them. Any platform holding a valid Registry-issued token can reach anything that syncs to it, and the legacy acl array is all-or-nothing — there is no read-without-write except ["*"], which is what essentially every record in the system is written with today.

What is here

A policy engine (infrastructure/evault-core/src/core/acl/) — pure, no I/O:

  • Permissions as a bitmask: 0x01 READ, 0x02 CREATE, 0x04 UPDATE, 0x08 DELETE. 0x03 expresses add-only access. Reserved bits 4-7 are rejected on write and stripped on read; 0x00 counts as no grant rather than an empty one.
  • Most-specific-wins: a user grant beats a platform grant beats a group grant, with no union across specificity. Grants tied at the same specificity are unioned, so the outcome does not depend on storage order.
  • Denials always win, by eName (matching the party, the platform carrying its request, or a group it belongs to) or by a failing condition.
  • Decision order: denials, then the single most specific grant, then the require groups against default_perms. A named party never falls through from a grant to default_perms.

Persistence — the block is stored as an aclBlock JSON property on :MetaEnvelope, carried through every read site, both store paths, the update path, and the cross-eVault migration copy. An update that omits _acl preserves the stored policy rather than clearing it.

EnforcementVaultAccessGuard now takes the permission each operation needs; all 21 resolvers are mapped. The consequential change:

A record carrying an _acl block is decided by that block for every caller, including a platform holding a valid Registry token. Closing that bypass is the point of the model.

Delegated identity — a platform's token proves the platform, not which of its users a request is for. X-ON-BEHALF-OF: @<ename> carries that: the named user becomes the party, with the carrying platform recorded alongside it, so a user grant applies at user specificity while a platform grant still applies at its own.

It is the platform's assertion, not a proof — the eVault cannot verify it, so a platform can reach what a user was granted, including more than its own grant. That is the deliberate reading of the design's specificity rule, and a platform that can write to a vault can already act as its users in other ways. What it cannot do is escape a denial: denials match the carrying platform too.

Only @-prefixed eNames are accepted as parties. This also fixes a real trap: currentUser is derived from the JWT kid, which for a Registry platform token is the literal string entropy-key-1 — a signing-key id, not a party. It is no longer treated as an identity.

Reading the policy backMetaEnvelope._acl is exposed to anyone permitted to read the record, as typed output (AclBlock/AclGrant/AclDenials/AclCondition). It always reports the policy in force: a record carrying only a legacy array reports the block that array maps to, so clients see one shape regardless of how the record was written. The legacy array itself is still never returned.

Note this means a denial list is visible to permitted readers — it names the parties an owner excluded.

GraphQL inputAclBlockInput and friends, with _acl on MetaEnvelopeInput, BulkMetaEnvelopeInput, and UploadFileInput.

Nothing narrows until an owner sets a policy

Every platform writes acl: ["*"] today, so back-compat mattered more than purity here. Records with no _acl keep their exact current behaviour, token bypass included. Legacy arrays are read as: ["*"]default_perms 0x0F behind an always-passing group; ["@ename"] → a 0x0F grant to that eName. Binding documents (acl: [subject]) are unaffected.

Docs

  • W3DS Protocol/Access-Control.md — the protocol model, wire format, decision order, the delegation header, and reading policies back.
  • Post Platform Guide/access-control.md — the implementation guide: worked policy shapes, the perms table, header usage, and the failure modes most likely to bite (a grant is final; 0 means no grant, not "denied"; a platform token will not open a policied record; policies replace rather than merge).
  • Infrastructure/eVault.md rewritten — it previously documented the old model as a limitation with granular permissions "planned for future versions".
  • The bundled skills/w3ds reference carried the same stale claim and is corrected; it is symlinked into installed agent skills.

Deliberately not in scope

  • Group membership is not resolved. A grant or denial naming a group matches nobody. Fail-closed for grants, but fail-open for group denials — so group denials are not usable until a membership lookup is wired into principalFor.
  • No condition evaluator is connected. Conditions currently fail closed: a require group containing conditions cannot pass, and a deny condition always fires. Policies should use grants, denials.enames, and empty-group require until one lands.
  • eVault only. The Web3 Adapter hardcodes acl: ["*"] with no _acl parameter and does not send X-ON-BEHALF-OF, so records written through handleChange cannot carry a policy or a delegated identity yet — a direct GraphQL call is needed. Platforms do not evaluate policies themselves.
  • The binary wire format from §9 of the design has no consumer in this layer.

Deploying

No migration. Neo4j is schemaless and aclBlock is a new optional property, so existing nodes take the legacy path untouched. Nothing can write a policy through the adapter yet, so the change is inert on deploy.

One caution: treat this as forward-only once policies start being written. Rolling back means old code ignoring aclBlock and reading the acl array, which platforms set to ["*"] — a locked-down record would become world-open again.

Testing

30 engine tests written from the design's normative examples, plus 19 guard integration tests against real Neo4j covering the token-bypass closure, specificity, deny-over-grant, default_perms, fail-closed conditions, delegated identity and its denial escape-hatch, rejection of non-eName identities, and legacy records staying unchanged. Full suite: 213/213 across 18 files. Typecheck clean; docs build passes with onBrokenLinks: 'throw'.

https://claude.ai/code/session_01UpwygDu2cizLp12tvvKqVZ

@coderabbitai

coderabbitaiBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 43fef62c-f09c-48a9-ae99-74e38cd7f1dc


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coodos
coodos merged commit 8079ee6 into mainAug 31, 2026
6 checks passed
@coodos
coodos deleted the feat/acl-permissions branch August 31, 2026 09:41
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@coodos
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Granular access control: per-verb permissions, denials, and the _acl block - #1122

Merged
coodos merged 7 commits into
mainfrom
feat/acl-permissions
Aug 31, 2026
Merged

Granular access control: per-verb permissions, denials, and the _acl block#1122
coodos merged 7 commits into
mainfrom
feat/acl-permissions

Conversation

@coodos

@coodoscoodos commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Records in an eVault now carry their own access rules: who may read, add, change, or delete them, held inside the record so the policy travels with the data when it syncs.

This implements the list half of the access control design — grants, denials, and the decision order. The Resource Link Ontology half (resolving a platform's score and comparing it) is left as an injected seam, since the scoring side already exists elsewhere.

The problem

Platforms have no access controls between them. Any platform holding a valid Registry-issued token can reach anything that syncs to it, and the legacy acl array is all-or-nothing — there is no read-without-write except ["*"], which is what essentially every record in the system is written with today.

What is here

A policy engine (infrastructure/evault-core/src/core/acl/) — pure, no I/O:

  • Permissions as a bitmask: 0x01 READ, 0x02 CREATE, 0x04 UPDATE, 0x08 DELETE. 0x03 expresses add-only access. Reserved bits 4-7 are rejected on write and stripped on read; 0x00 counts as no grant rather than an empty one.
  • Most-specific-wins: a user grant beats a platform grant beats a group grant, with no union across specificity. Grants tied at the same specificity are unioned, so the outcome does not depend on storage order.
  • Denials always win, by eName (matching the party, the platform carrying its request, or a group it belongs to) or by a failing condition.
  • Decision order: denials, then the single most specific grant, then the require groups against default_perms. A named party never falls through from a grant to default_perms.

Persistence — the block is stored as an aclBlock JSON property on :MetaEnvelope, carried through every read site, both store paths, the update path, and the cross-eVault migration copy. An update that omits _acl preserves the stored policy rather than clearing it.

EnforcementVaultAccessGuard now takes the permission each operation needs; all 21 resolvers are mapped. The consequential change:

A record carrying an _acl block is decided by that block for every caller, including a platform holding a valid Registry token. Closing that bypass is the point of the model.

Delegated identity — a platform's token proves the platform, not which of its users a request is for. X-ON-BEHALF-OF: @<ename> carries that: the named user becomes the party, with the carrying platform recorded alongside it, so a user grant applies at user specificity while a platform grant still applies at its own.

It is the platform's assertion, not a proof — the eVault cannot verify it, so a platform can reach what a user was granted, including more than its own grant. That is the deliberate reading of the design's specificity rule, and a platform that can write to a vault can already act as its users in other ways. What it cannot do is escape a denial: denials match the carrying platform too.

Only @-prefixed eNames are accepted as parties. This also fixes a real trap: currentUser is derived from the JWT kid, which for a Registry platform token is the literal string entropy-key-1 — a signing-key id, not a party. It is no longer treated as an identity.

Reading the policy backMetaEnvelope._acl is exposed to anyone permitted to read the record, as typed output (AclBlock/AclGrant/AclDenials/AclCondition). It always reports the policy in force: a record carrying only a legacy array reports the block that array maps to, so clients see one shape regardless of how the record was written. The legacy array itself is still never returned.

Note this means a denial list is visible to permitted readers — it names the parties an owner excluded.

GraphQL inputAclBlockInput and friends, with _acl on MetaEnvelopeInput, BulkMetaEnvelopeInput, and UploadFileInput.

Nothing narrows until an owner sets a policy

Every platform writes acl: ["*"] today, so back-compat mattered more than purity here. Records with no _acl keep their exact current behaviour, token bypass included. Legacy arrays are read as: ["*"]default_perms 0x0F behind an always-passing group; ["@ename"] → a 0x0F grant to that eName. Binding documents (acl: [subject]) are unaffected.

Docs

  • W3DS Protocol/Access-Control.md — the protocol model, wire format, decision order, the delegation header, and reading policies back.
  • Post Platform Guide/access-control.md — the implementation guide: worked policy shapes, the perms table, header usage, and the failure modes most likely to bite (a grant is final; 0 means no grant, not "denied"; a platform token will not open a policied record; policies replace rather than merge).
  • Infrastructure/eVault.md rewritten — it previously documented the old model as a limitation with granular permissions "planned for future versions".
  • The bundled skills/w3ds reference carried the same stale claim and is corrected; it is symlinked into installed agent skills.

Deliberately not in scope

  • Group membership is not resolved. A grant or denial naming a group matches nobody. Fail-closed for grants, but fail-open for group denials — so group denials are not usable until a membership lookup is wired into principalFor.
  • No condition evaluator is connected. Conditions currently fail closed: a require group containing conditions cannot pass, and a deny condition always fires. Policies should use grants, denials.enames, and empty-group require until one lands.
  • eVault only. The Web3 Adapter hardcodes acl: ["*"] with no _acl parameter and does not send X-ON-BEHALF-OF, so records written through handleChange cannot carry a policy or a delegated identity yet — a direct GraphQL call is needed. Platforms do not evaluate policies themselves.
  • The binary wire format from §9 of the design has no consumer in this layer.

Deploying

No migration. Neo4j is schemaless and aclBlock is a new optional property, so existing nodes take the legacy path untouched. Nothing can write a policy through the adapter yet, so the change is inert on deploy.

One caution: treat this as forward-only once policies start being written. Rolling back means old code ignoring aclBlock and reading the acl array, which platforms set to ["*"] — a locked-down record would become world-open again.

Testing

30 engine tests written from the design's normative examples, plus 19 guard integration tests against real Neo4j covering the token-bypass closure, specificity, deny-over-grant, default_perms, fail-closed conditions, delegated identity and its denial escape-hatch, rejection of non-eName identities, and legacy records staying unchanged. Full suite: 213/213 across 18 files. Typecheck clean; docs build passes with onBrokenLinks: 'throw'.

https://claude.ai/code/session_01UpwygDu2cizLp12tvvKqVZ

@coderabbitai

coderabbitaiBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 43fef62c-f09c-48a9-ae99-74e38cd7f1dc


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coodos
coodos merged commit 8079ee6 into mainAug 31, 2026
6 checks passed
@coodos
coodos deleted the feat/acl-permissions branch August 31, 2026 09:41
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@coodos
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Granular access control: per-verb permissions, denials, and the _acl block - #1122

Merged
coodos merged 7 commits into
mainfrom
feat/acl-permissions
Aug 31, 2026
Merged

Granular access control: per-verb permissions, denials, and the _acl block#1122
coodos merged 7 commits into
mainfrom
feat/acl-permissions

Conversation

@coodos

@coodoscoodos commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Records in an eVault now carry their own access rules: who may read, add, change, or delete them, held inside the record so the policy travels with the data when it syncs.

This implements the list half of the access control design — grants, denials, and the decision order. The Resource Link Ontology half (resolving a platform's score and comparing it) is left as an injected seam, since the scoring side already exists elsewhere.

The problem

Platforms have no access controls between them. Any platform holding a valid Registry-issued token can reach anything that syncs to it, and the legacy acl array is all-or-nothing — there is no read-without-write except ["*"], which is what essentially every record in the system is written with today.

What is here

A policy engine (infrastructure/evault-core/src/core/acl/) — pure, no I/O:

  • Permissions as a bitmask: 0x01 READ, 0x02 CREATE, 0x04 UPDATE, 0x08 DELETE. 0x03 expresses add-only access. Reserved bits 4-7 are rejected on write and stripped on read; 0x00 counts as no grant rather than an empty one.
  • Most-specific-wins: a user grant beats a platform grant beats a group grant, with no union across specificity. Grants tied at the same specificity are unioned, so the outcome does not depend on storage order.
  • Denials always win, by eName (matching the party, the platform carrying its request, or a group it belongs to) or by a failing condition.
  • Decision order: denials, then the single most specific grant, then the require groups against default_perms. A named party never falls through from a grant to default_perms.

Persistence — the block is stored as an aclBlock JSON property on :MetaEnvelope, carried through every read site, both store paths, the update path, and the cross-eVault migration copy. An update that omits _acl preserves the stored policy rather than clearing it.

EnforcementVaultAccessGuard now takes the permission each operation needs; all 21 resolvers are mapped. The consequential change:

A record carrying an _acl block is decided by that block for every caller, including a platform holding a valid Registry token. Closing that bypass is the point of the model.

Delegated identity — a platform's token proves the platform, not which of its users a request is for. X-ON-BEHALF-OF: @<ename> carries that: the named user becomes the party, with the carrying platform recorded alongside it, so a user grant applies at user specificity while a platform grant still applies at its own.

It is the platform's assertion, not a proof — the eVault cannot verify it, so a platform can reach what a user was granted, including more than its own grant. That is the deliberate reading of the design's specificity rule, and a platform that can write to a vault can already act as its users in other ways. What it cannot do is escape a denial: denials match the carrying platform too.

Only @-prefixed eNames are accepted as parties. This also fixes a real trap: currentUser is derived from the JWT kid, which for a Registry platform token is the literal string entropy-key-1 — a signing-key id, not a party. It is no longer treated as an identity.

Reading the policy backMetaEnvelope._acl is exposed to anyone permitted to read the record, as typed output (AclBlock/AclGrant/AclDenials/AclCondition). It always reports the policy in force: a record carrying only a legacy array reports the block that array maps to, so clients see one shape regardless of how the record was written. The legacy array itself is still never returned.

Note this means a denial list is visible to permitted readers — it names the parties an owner excluded.

GraphQL inputAclBlockInput and friends, with _acl on MetaEnvelopeInput, BulkMetaEnvelopeInput, and UploadFileInput.

Nothing narrows until an owner sets a policy

Every platform writes acl: ["*"] today, so back-compat mattered more than purity here. Records with no _acl keep their exact current behaviour, token bypass included. Legacy arrays are read as: ["*"]default_perms 0x0F behind an always-passing group; ["@ename"] → a 0x0F grant to that eName. Binding documents (acl: [subject]) are unaffected.

Docs

  • W3DS Protocol/Access-Control.md — the protocol model, wire format, decision order, the delegation header, and reading policies back.
  • Post Platform Guide/access-control.md — the implementation guide: worked policy shapes, the perms table, header usage, and the failure modes most likely to bite (a grant is final; 0 means no grant, not "denied"; a platform token will not open a policied record; policies replace rather than merge).
  • Infrastructure/eVault.md rewritten — it previously documented the old model as a limitation with granular permissions "planned for future versions".
  • The bundled skills/w3ds reference carried the same stale claim and is corrected; it is symlinked into installed agent skills.

Deliberately not in scope

  • Group membership is not resolved. A grant or denial naming a group matches nobody. Fail-closed for grants, but fail-open for group denials — so group denials are not usable until a membership lookup is wired into principalFor.
  • No condition evaluator is connected. Conditions currently fail closed: a require group containing conditions cannot pass, and a deny condition always fires. Policies should use grants, denials.enames, and empty-group require until one lands.
  • eVault only. The Web3 Adapter hardcodes acl: ["*"] with no _acl parameter and does not send X-ON-BEHALF-OF, so records written through handleChange cannot carry a policy or a delegated identity yet — a direct GraphQL call is needed. Platforms do not evaluate policies themselves.
  • The binary wire format from §9 of the design has no consumer in this layer.

Deploying

No migration. Neo4j is schemaless and aclBlock is a new optional property, so existing nodes take the legacy path untouched. Nothing can write a policy through the adapter yet, so the change is inert on deploy.

One caution: treat this as forward-only once policies start being written. Rolling back means old code ignoring aclBlock and reading the acl array, which platforms set to ["*"] — a locked-down record would become world-open again.

Testing

30 engine tests written from the design's normative examples, plus 19 guard integration tests against real Neo4j covering the token-bypass closure, specificity, deny-over-grant, default_perms, fail-closed conditions, delegated identity and its denial escape-hatch, rejection of non-eName identities, and legacy records staying unchanged. Full suite: 213/213 across 18 files. Typecheck clean; docs build passes with onBrokenLinks: 'throw'.

https://claude.ai/code/session_01UpwygDu2cizLp12tvvKqVZ

@coderabbitai

coderabbitaiBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 43fef62c-f09c-48a9-ae99-74e38cd7f1dc


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coodos
coodos merged commit 8079ee6 into mainAug 31, 2026
6 checks passed
@coodos
coodos deleted the feat/acl-permissions branch August 31, 2026 09:41
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@coodos
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Granular access control: per-verb permissions, denials, and the _acl block - #1122

Merged
coodos merged 7 commits into
mainfrom
feat/acl-permissions
Aug 31, 2026
Merged

Granular access control: per-verb permissions, denials, and the _acl block#1122
coodos merged 7 commits into
mainfrom
feat/acl-permissions

Conversation

@coodos

@coodoscoodos commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Records in an eVault now carry their own access rules: who may read, add, change, or delete them, held inside the record so the policy travels with the data when it syncs.

This implements the list half of the access control design — grants, denials, and the decision order. The Resource Link Ontology half (resolving a platform's score and comparing it) is left as an injected seam, since the scoring side already exists elsewhere.

The problem

Platforms have no access controls between them. Any platform holding a valid Registry-issued token can reach anything that syncs to it, and the legacy acl array is all-or-nothing — there is no read-without-write except ["*"], which is what essentially every record in the system is written with today.

What is here

A policy engine (infrastructure/evault-core/src/core/acl/) — pure, no I/O:

  • Permissions as a bitmask: 0x01 READ, 0x02 CREATE, 0x04 UPDATE, 0x08 DELETE. 0x03 expresses add-only access. Reserved bits 4-7 are rejected on write and stripped on read; 0x00 counts as no grant rather than an empty one.
  • Most-specific-wins: a user grant beats a platform grant beats a group grant, with no union across specificity. Grants tied at the same specificity are unioned, so the outcome does not depend on storage order.
  • Denials always win, by eName (matching the party, the platform carrying its request, or a group it belongs to) or by a failing condition.
  • Decision order: denials, then the single most specific grant, then the require groups against default_perms. A named party never falls through from a grant to default_perms.

Persistence — the block is stored as an aclBlock JSON property on :MetaEnvelope, carried through every read site, both store paths, the update path, and the cross-eVault migration copy. An update that omits _acl preserves the stored policy rather than clearing it.

EnforcementVaultAccessGuard now takes the permission each operation needs; all 21 resolvers are mapped. The consequential change:

A record carrying an _acl block is decided by that block for every caller, including a platform holding a valid Registry token. Closing that bypass is the point of the model.

Delegated identity — a platform's token proves the platform, not which of its users a request is for. X-ON-BEHALF-OF: @<ename> carries that: the named user becomes the party, with the carrying platform recorded alongside it, so a user grant applies at user specificity while a platform grant still applies at its own.

It is the platform's assertion, not a proof — the eVault cannot verify it, so a platform can reach what a user was granted, including more than its own grant. That is the deliberate reading of the design's specificity rule, and a platform that can write to a vault can already act as its users in other ways. What it cannot do is escape a denial: denials match the carrying platform too.

Only @-prefixed eNames are accepted as parties. This also fixes a real trap: currentUser is derived from the JWT kid, which for a Registry platform token is the literal string entropy-key-1 — a signing-key id, not a party. It is no longer treated as an identity.

Reading the policy backMetaEnvelope._acl is exposed to anyone permitted to read the record, as typed output (AclBlock/AclGrant/AclDenials/AclCondition). It always reports the policy in force: a record carrying only a legacy array reports the block that array maps to, so clients see one shape regardless of how the record was written. The legacy array itself is still never returned.

Note this means a denial list is visible to permitted readers — it names the parties an owner excluded.

GraphQL inputAclBlockInput and friends, with _acl on MetaEnvelopeInput, BulkMetaEnvelopeInput, and UploadFileInput.

Nothing narrows until an owner sets a policy

Every platform writes acl: ["*"] today, so back-compat mattered more than purity here. Records with no _acl keep their exact current behaviour, token bypass included. Legacy arrays are read as: ["*"]default_perms 0x0F behind an always-passing group; ["@ename"] → a 0x0F grant to that eName. Binding documents (acl: [subject]) are unaffected.

Docs

  • W3DS Protocol/Access-Control.md — the protocol model, wire format, decision order, the delegation header, and reading policies back.
  • Post Platform Guide/access-control.md — the implementation guide: worked policy shapes, the perms table, header usage, and the failure modes most likely to bite (a grant is final; 0 means no grant, not "denied"; a platform token will not open a policied record; policies replace rather than merge).
  • Infrastructure/eVault.md rewritten — it previously documented the old model as a limitation with granular permissions "planned for future versions".
  • The bundled skills/w3ds reference carried the same stale claim and is corrected; it is symlinked into installed agent skills.

Deliberately not in scope

  • Group membership is not resolved. A grant or denial naming a group matches nobody. Fail-closed for grants, but fail-open for group denials — so group denials are not usable until a membership lookup is wired into principalFor.
  • No condition evaluator is connected. Conditions currently fail closed: a require group containing conditions cannot pass, and a deny condition always fires. Policies should use grants, denials.enames, and empty-group require until one lands.
  • eVault only. The Web3 Adapter hardcodes acl: ["*"] with no _acl parameter and does not send X-ON-BEHALF-OF, so records written through handleChange cannot carry a policy or a delegated identity yet — a direct GraphQL call is needed. Platforms do not evaluate policies themselves.
  • The binary wire format from §9 of the design has no consumer in this layer.

Deploying

No migration. Neo4j is schemaless and aclBlock is a new optional property, so existing nodes take the legacy path untouched. Nothing can write a policy through the adapter yet, so the change is inert on deploy.

One caution: treat this as forward-only once policies start being written. Rolling back means old code ignoring aclBlock and reading the acl array, which platforms set to ["*"] — a locked-down record would become world-open again.

Testing

30 engine tests written from the design's normative examples, plus 19 guard integration tests against real Neo4j covering the token-bypass closure, specificity, deny-over-grant, default_perms, fail-closed conditions, delegated identity and its denial escape-hatch, rejection of non-eName identities, and legacy records staying unchanged. Full suite: 213/213 across 18 files. Typecheck clean; docs build passes with onBrokenLinks: 'throw'.

https://claude.ai/code/session_01UpwygDu2cizLp12tvvKqVZ

@coderabbitai

coderabbitaiBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 43fef62c-f09c-48a9-ae99-74e38cd7f1dc


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coodos
coodos merged commit 8079ee6 into mainAug 31, 2026
6 checks passed
@coodos
coodos deleted the feat/acl-permissions branch August 31, 2026 09:41
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@coodos