[Phase 3] Add ACL fields to Context type and wire into lifecycle hooks #16

Open
opened 2026-03-26 11:25:15 +00:00 by timur · 3 comments
Owner

Summary

The OSIS Context type currently has no access control fields. For multi-tenant deployments (e.g., znzfreezone with per-reseller contexts), we need ACL fields on the Context itself so that lifecycle hooks can enforce who is allowed to read/write/admin each context.

Background

In the znzfreezone architecture:

  • freezone_admin context: authoritative records, admin-only
  • freezone_shared context: request/approval queue, admin + resellers can write
  • freezone_reseller_{id} contexts: per-reseller private workspaces

Currently, access control is enforced entirely in znzfreezone_backend's rpc_auth.rs middleware. This works but means every application using hero_osis multi-context must re-implement context-level ACL. It should be a first-class OSIS feature.

Note: hero_rpc already has a full ACL module (crates/osis/src/acl.rs, ~820 lines) with hierarchical Rights (Admin/Write/Read), Group-based membership, and recursive resolution — but it is not wired into the dispatch pipeline.

Proposed Changes

1. Add ACL fields to Context type

In base/core/types.oschema, add to the Context type:

owner_pubkey: String           // secp256k1 public key of context creator
acl_admin_pubkeys: [String]    // can manage context settings + full CRUD
acl_write_pubkeys: [String]    // can create/update/delete objects
acl_read_pubkeys: [String]     // can read objects

These get auto-generated into base/types_generated.rs by the oschema codegen.

2. Wire ACL checks into lifecycle hooks

The lifecycle hook stubs in hero_osis_server (before_create, before_update, before_delete, before_read) should check the caller's identity against the context's ACL fields.

This requires the caller identity to be passed through the RPC dispatch pipeline (see related hero_rpc issue for RequestContext).

3. Context creation with ACL initialization

When a new context is registered via ContextLifecycleRequest, the creator's public key should be set as owner_pubkey and added to acl_admin_pubkeys by default.

Integration with hero_rpc ACL module

The existing hero_rpc ACL module (acl.rs) provides:

  • AclEntry with circle/group resolution
  • Rights enum (Admin, Write, Read)
  • check_access(identity, required_right) logic

Consider whether to:

  • Option A: Use the simple pubkey list approach above (sufficient for znzfreezone)
  • Option B: Integrate with the full hero_rpc ACL module for group-based resolution

Recommendation: Start with Option A (simple pubkey lists) and migrate to Option B later if group-based ACL is needed.

  • znzfreezone_code/znzfreezone_backend issue #29: Cross-context authoritative signatures
  • lhumina_code/hero_rpc: Needs RequestContext to pass caller identity (separate issue)
  • lhumina_code/hero_proxy: Identity header injection (separate issue)
  • hero_rpc ACL module at crates/osis/src/acl.rs
## Summary The OSIS Context type currently has no access control fields. For multi-tenant deployments (e.g., znzfreezone with per-reseller contexts), we need ACL fields on the Context itself so that lifecycle hooks can enforce who is allowed to read/write/admin each context. ## Background In the znzfreezone architecture: - `freezone_admin` context: authoritative records, admin-only - `freezone_shared` context: request/approval queue, admin + resellers can write - `freezone_reseller_{id}` contexts: per-reseller private workspaces Currently, access control is enforced entirely in `znzfreezone_backend`'s `rpc_auth.rs` middleware. This works but means every application using hero_osis multi-context must re-implement context-level ACL. It should be a first-class OSIS feature. Note: `hero_rpc` already has a full ACL module (`crates/osis/src/acl.rs`, ~820 lines) with hierarchical Rights (Admin/Write/Read), Group-based membership, and recursive resolution — but it is **not wired into the dispatch pipeline**. ## Proposed Changes ### 1. Add ACL fields to Context type In `base/core/types.oschema`, add to the Context type: ``` owner_pubkey: String // secp256k1 public key of context creator acl_admin_pubkeys: [String] // can manage context settings + full CRUD acl_write_pubkeys: [String] // can create/update/delete objects acl_read_pubkeys: [String] // can read objects ``` These get auto-generated into `base/types_generated.rs` by the oschema codegen. ### 2. Wire ACL checks into lifecycle hooks The lifecycle hook stubs in `hero_osis_server` (`before_create`, `before_update`, `before_delete`, `before_read`) should check the caller's identity against the context's ACL fields. This requires the caller identity to be passed through the RPC dispatch pipeline (see related hero_rpc issue for `RequestContext`). ### 3. Context creation with ACL initialization When a new context is registered via `ContextLifecycleRequest`, the creator's public key should be set as `owner_pubkey` and added to `acl_admin_pubkeys` by default. ## Integration with hero_rpc ACL module The existing `hero_rpc` ACL module (`acl.rs`) provides: - `AclEntry` with circle/group resolution - `Rights` enum (Admin, Write, Read) - `check_access(identity, required_right)` logic Consider whether to: - **Option A**: Use the simple pubkey list approach above (sufficient for znzfreezone) - **Option B**: Integrate with the full hero_rpc ACL module for group-based resolution Recommendation: Start with Option A (simple pubkey lists) and migrate to Option B later if group-based ACL is needed. ## Related - `znzfreezone_code/znzfreezone_backend` issue #29: Cross-context authoritative signatures - `lhumina_code/hero_rpc`: Needs RequestContext to pass caller identity (separate issue) - `lhumina_code/hero_proxy`: Identity header injection (separate issue) - `hero_rpc` ACL module at `crates/osis/src/acl.rs`
Author
Owner

Update: Keypair as Primary Identity Primitive

Following discussion on hero_proxy#8, authentication is moving to hero_proxy with keypair-only auth for programmatic access (API keys are being dropped).

This means the ACL fields on Context should use secp256k1 public keys as the identity primitive:

owner_pubkey: str              # secp256k1 pubkey of context creator
acl_admin_pubkeys: [str]       # can manage context settings + full CRUD
acl_write_pubkeys: [str]       # can create/update/delete objects
acl_read_pubkeys: [str]        # can read objects

The caller identity comes from hero_proxy's X-Proxy-User-Pubkey header (injected after signature verification), which flows through hero_rpc's RequestContext (hero_rpc#11) into lifecycle hooks.

This aligns with the simplified auth model:

  • hero_proxy: authentication (sig verification) → injects pubkey header
  • hero_rpc: identity propagation (RequestContext with caller_pubkey)
  • hero_osis: access control (check caller_pubkey against context ACL)
  • application: business logic authorization
## Update: Keypair as Primary Identity Primitive Following discussion on [hero_proxy#8](https://forge.ourworld.tf/lhumina_code/hero_proxy/issues/8), authentication is moving to hero_proxy with keypair-only auth for programmatic access (API keys are being dropped). This means the ACL fields on Context should use **secp256k1 public keys** as the identity primitive: ``` owner_pubkey: str # secp256k1 pubkey of context creator acl_admin_pubkeys: [str] # can manage context settings + full CRUD acl_write_pubkeys: [str] # can create/update/delete objects acl_read_pubkeys: [str] # can read objects ``` The caller identity comes from hero_proxy's `X-Proxy-User-Pubkey` header (injected after signature verification), which flows through hero_rpc's `RequestContext` ([hero_rpc#11](https://forge.ourworld.tf/lhumina_code/hero_rpc/issues/11)) into lifecycle hooks. This aligns with the simplified auth model: - hero_proxy: authentication (sig verification) → injects pubkey header - hero_rpc: identity propagation (RequestContext with caller_pubkey) - hero_osis: access control (check caller_pubkey against context ACL) - application: business logic authorization
Author
Owner
  1. i think we should have a single field called acl which is a list of access control entries. then an entry can have an audience (list of groups + identity ids) and permissions.

  2. yes.

as for integration:

  • acl module should probably be a domain in hero_osis, instead of residing in hero_rpc. perhaps it even also exists there
  • this way the hero_osis can be used as backend to store access control as already context is also stored there. so basically merge hero_rpc acl into hero_osis by creating schemas for it or updating existing schemas since some already exist: https://forge.ourworld.tf/lhumina_code/hero_osis and we shall have automatically a rust functions generated our freezone backend can use.
1. i think we should have a single field called acl which is a list of access control entries. then an entry can have an audience (list of groups + identity ids) and permissions. 2. yes. as for integration: - acl module should probably be a domain in hero_osis, instead of residing in hero_rpc. perhaps it even also exists there - this way the hero_osis can be used as backend to store access control as already context is also stored there. so basically merge hero_rpc acl into hero_osis by creating schemas for it or updating existing schemas since some already exist: https://forge.ourworld.tf/lhumina_code/hero_osis and we shall have automatically a rust functions generated our freezone backend can use.
Author
Owner

Analysis: ACL as a first-class OSIS domain

Explored the codebase to map out what exists and what needs to change. Here's the full picture.

What exists today

hero_rpc crates/server/src/acl/mod.rs (~820 lines) has a solid in-memory ACL implementation:

  • Right enum: Admin > Write > Read (hierarchical implication)
  • Group: named collection of users (by pubkey) + nested groups (by name), recursive membership resolution with circular-reference protection
  • Ace: links a Right to a list of group names
  • Acl: groups map + entries list, with has_right(pubkey, right), get_highest_right(pubkey), get_user_groups(pubkey)

hero_osis already has ACL-adjacent schemas:

  • identity/group.oschemaGroup rootobject with GroupMember (user_id + GroupRole: reader/writer/admin/owner), subgroups, parent_group, hierarchy
  • ledger/groups.oschemaGroups service with MemberId (Account|Group), Role (Viewer/Contributor/Member/Coordinator), admin management, circular membership prevention
  • ledger/kvs.oschema — namespace-scoped admin/writer ACL pattern

Current Context type (base/core.oschema) is minimal — just sid, name, created_at, updated_at. No ACL fields.


Proposed approach: single acl field on Context

Per comment #15695 — instead of flat pubkey lists, Context gets a single structured acl field. This maps directly to the hero_rpc ACL model, expressed as OSchema types:

# In schemas/base/acl.oschema (new file, base domain)

# Permission level — hierarchical: Admin > Write > Read
AclRight = "admin" | "write" | "read"

# A principal in the ACL — either a direct identity or a group reference
AclPrincipal = {
    type: "identity" | "group"
    id: str                    # secp256k1 pubkey (identity) or group name (group)
}

# Access control entry — grants a right to a set of principals
AclEntry = {
    right: AclRight
    audience: [AclPrincipal]   # who gets this right
}

# Access control group — named collection of identities + nested groups
AclGroup = {
    sid: sid
    name: str                  # [index]
    description?: str
    members: [AclPrincipal]    # direct members (identities + groups)
    created_at: u64
    updated_at: u64
}

Then Context gains:

Context = {
    sid: sid
    name: str                  # [index]
    owner_pubkey: str           # secp256k1 pubkey of context creator
    acl: [AclEntry]             # access control entries
    created_at: u64
    updated_at: u64
}

This gives us:

  • Single acl field = list of entries, each with audience (groups + identities) and permission level
  • AclGroup as rootobject = groups are persisted via OSIS CRUD, auto-generated acl_group_get(), acl_group_set(), acl_group_list(), etc.
  • Audience references groups by name — resolution walks AclGroup objects stored in the same context

Making ACL a domain in hero_osis

Two options for where the ACL types + service live:

Option A: Extend base domain — add acl.oschema to schemas/base/. The base domain already owns Context, so ACL types naturally belong here. AclGroup becomes a rootobject alongside Context, Server, Template.

Option B: New acl domainschemas/acl/acl.oschema. Cleaner separation, own database file per context (acl.db), own service handler. But creates a cross-domain dependency since Context (base) references AclEntry (acl).

Recommendation: Option A (extend base domain). Context already lives in base, and AclEntry is an embedded type (not a rootobject), so it's just a nested struct on Context. Only AclGroup needs to be a rootobject (for group CRUD). This avoids cross-domain references.


ACL service methods

Beyond auto-generated CRUD, we need an AclService with business logic:

service AclService {
    # Check if a pubkey has a specific right on this context
    check_access(pubkey: str, required_right: AclRight) -> bool

    # Get the highest right a pubkey has
    get_highest_right(pubkey: str) -> AclRight
        error: NoAccess

    # Grant a right to a principal
    grant(entry: AclEntry)
        error: NotAuthorized

    # Revoke a right from a principal
    revoke(right: AclRight, principal: AclPrincipal)
        error: NotAuthorized

    # Resolve all groups a pubkey belongs to (recursive)
    resolve_groups(pubkey: str) -> [str]
}

The implementation in base/rpc.rs reuses the resolution logic from hero_rpc's Acl::get_user_groups() and Acl::has_right(), but reads groups from OSIS storage instead of in-memory structs.


Wiring into lifecycle hooks

The existing trigger pattern (*_trigger_get_pre, *_trigger_save_pre, *_trigger_delete_pre) returns bool — perfect for ACL enforcement. The question is how the caller's pubkey reaches the hook.

This depends on hero_rpc's RequestContext (hero_rpc#11). Once that lands, the flow is:

  1. hero_proxy verifies signature → injects X-Proxy-User-Pubkey header
  2. hero_rpc extracts header → populates RequestContext.caller_pubkey
  3. OSIS dispatch passes RequestContext to lifecycle hooks
  4. Hook calls AclService::check_access(caller_pubkey, Right::Write) → allows/denies

The hook signatures would need to change from fn trigger_save_pre(obj: &mut T) -> bool to something like fn trigger_save_pre(obj: &mut T, ctx: &RequestContext) -> bool. This is a codegen change in the OSIS server generator.


Migration path for hero_rpc ACL module

The hero_rpc acl/mod.rs becomes a consumer of OSIS ACL data rather than the source of truth:

  • Keep the Rust types (Right, Group, Ace, Acl) in hero_rpc for in-memory evaluation (fast path)
  • OSIS stores the canonical ACL data (AclGroup rootobjects, AclEntry on Context)
  • On context load, materialize an Acl struct from stored AclGroups + Context.acl for fast checking
  • hero_rpc can re-export or depend on the generated OSIS types

This way the hero_rpc ACL module doesn't disappear — it becomes the evaluation engine, while OSIS is the persistence layer.


What znzfreezone gets

With this in place, znzfreezone_backend can drop its custom rpc_auth.rs middleware and instead:

  1. On context creation: set owner_pubkey to the admin's pubkey, add AclEntries granting admin/write/read to the appropriate groups
  2. Per-reseller contexts: create AclGroup per reseller, add their pubkey, grant write
  3. All enforcement happens in OSIS lifecycle hooks — zero custom auth code in the application

Implementation order

  1. Add OSchema types: AclRight, AclPrincipal, AclEntry, AclGroup to schemas/base/acl.oschema
  2. Add owner_pubkey + acl fields to Context in schemas/base/core.oschema
  3. Regenerate types — auto-generates Rust structs + CRUD for AclGroup
  4. Add AclService with check_access, grant, revoke, resolve_groups
  5. Update lifecycle hook signatures to receive RequestContext (blocked on hero_rpc#11)
  6. Wire ACL checks into hooks for all domains
  7. Update znzfreezone to use OSIS ACL instead of custom middleware
## Analysis: ACL as a first-class OSIS domain Explored the codebase to map out what exists and what needs to change. Here's the full picture. ### What exists today **hero_rpc** `crates/server/src/acl/mod.rs` (~820 lines) has a solid in-memory ACL implementation: - `Right` enum: `Admin > Write > Read` (hierarchical implication) - `Group`: named collection of users (by pubkey) + nested groups (by name), recursive membership resolution with circular-reference protection - `Ace`: links a `Right` to a list of group names - `Acl`: groups map + entries list, with `has_right(pubkey, right)`, `get_highest_right(pubkey)`, `get_user_groups(pubkey)` **hero_osis** already has ACL-adjacent schemas: - `identity/group.oschema` — `Group` rootobject with `GroupMember` (user_id + `GroupRole`: reader/writer/admin/owner), subgroups, parent_group, hierarchy - `ledger/groups.oschema` — `Groups` service with `MemberId` (Account|Group), `Role` (Viewer/Contributor/Member/Coordinator), admin management, circular membership prevention - `ledger/kvs.oschema` — namespace-scoped admin/writer ACL pattern **Current Context type** (`base/core.oschema`) is minimal — just `sid`, `name`, `created_at`, `updated_at`. No ACL fields. --- ### Proposed approach: single `acl` field on Context Per comment #15695 — instead of flat pubkey lists, Context gets a single structured `acl` field. This maps directly to the hero_rpc ACL model, expressed as OSchema types: ``` # In schemas/base/acl.oschema (new file, base domain) # Permission level — hierarchical: Admin > Write > Read AclRight = "admin" | "write" | "read" # A principal in the ACL — either a direct identity or a group reference AclPrincipal = { type: "identity" | "group" id: str # secp256k1 pubkey (identity) or group name (group) } # Access control entry — grants a right to a set of principals AclEntry = { right: AclRight audience: [AclPrincipal] # who gets this right } # Access control group — named collection of identities + nested groups AclGroup = { sid: sid name: str # [index] description?: str members: [AclPrincipal] # direct members (identities + groups) created_at: u64 updated_at: u64 } ``` Then Context gains: ``` Context = { sid: sid name: str # [index] owner_pubkey: str # secp256k1 pubkey of context creator acl: [AclEntry] # access control entries created_at: u64 updated_at: u64 } ``` This gives us: - **Single `acl` field** = list of entries, each with audience (groups + identities) and permission level - **`AclGroup` as rootobject** = groups are persisted via OSIS CRUD, auto-generated `acl_group_get()`, `acl_group_set()`, `acl_group_list()`, etc. - **Audience references groups by name** — resolution walks AclGroup objects stored in the same context --- ### Making ACL a domain in hero_osis Two options for where the ACL types + service live: **Option A: Extend base domain** — add `acl.oschema` to `schemas/base/`. The base domain already owns Context, so ACL types naturally belong here. AclGroup becomes a rootobject alongside Context, Server, Template. **Option B: New `acl` domain** — `schemas/acl/acl.oschema`. Cleaner separation, own database file per context (`acl.db`), own service handler. But creates a cross-domain dependency since Context (base) references AclEntry (acl). **Recommendation: Option A (extend base domain)**. Context already lives in base, and AclEntry is an embedded type (not a rootobject), so it's just a nested struct on Context. Only AclGroup needs to be a rootobject (for group CRUD). This avoids cross-domain references. --- ### ACL service methods Beyond auto-generated CRUD, we need an `AclService` with business logic: ``` service AclService { # Check if a pubkey has a specific right on this context check_access(pubkey: str, required_right: AclRight) -> bool # Get the highest right a pubkey has get_highest_right(pubkey: str) -> AclRight error: NoAccess # Grant a right to a principal grant(entry: AclEntry) error: NotAuthorized # Revoke a right from a principal revoke(right: AclRight, principal: AclPrincipal) error: NotAuthorized # Resolve all groups a pubkey belongs to (recursive) resolve_groups(pubkey: str) -> [str] } ``` The implementation in `base/rpc.rs` reuses the resolution logic from hero_rpc's `Acl::get_user_groups()` and `Acl::has_right()`, but reads groups from OSIS storage instead of in-memory structs. --- ### Wiring into lifecycle hooks The existing trigger pattern (`*_trigger_get_pre`, `*_trigger_save_pre`, `*_trigger_delete_pre`) returns `bool` — perfect for ACL enforcement. The question is how the caller's pubkey reaches the hook. This depends on hero_rpc's `RequestContext` ([hero_rpc#11](https://forge.ourworld.tf/lhumina_code/hero_rpc/issues/11)). Once that lands, the flow is: 1. hero_proxy verifies signature → injects `X-Proxy-User-Pubkey` header 2. hero_rpc extracts header → populates `RequestContext.caller_pubkey` 3. OSIS dispatch passes `RequestContext` to lifecycle hooks 4. Hook calls `AclService::check_access(caller_pubkey, Right::Write)` → allows/denies The hook signatures would need to change from `fn trigger_save_pre(obj: &mut T) -> bool` to something like `fn trigger_save_pre(obj: &mut T, ctx: &RequestContext) -> bool`. This is a codegen change in the OSIS server generator. --- ### Migration path for hero_rpc ACL module The hero_rpc `acl/mod.rs` becomes a **consumer** of OSIS ACL data rather than the source of truth: - Keep the Rust types (`Right`, `Group`, `Ace`, `Acl`) in hero_rpc for in-memory evaluation (fast path) - OSIS stores the canonical ACL data (AclGroup rootobjects, AclEntry on Context) - On context load, materialize an `Acl` struct from stored AclGroups + Context.acl for fast checking - hero_rpc can re-export or depend on the generated OSIS types This way the hero_rpc ACL module doesn't disappear — it becomes the evaluation engine, while OSIS is the persistence layer. --- ### What znzfreezone gets With this in place, `znzfreezone_backend` can drop its custom `rpc_auth.rs` middleware and instead: 1. On context creation: set `owner_pubkey` to the admin's pubkey, add AclEntries granting admin/write/read to the appropriate groups 2. Per-reseller contexts: create AclGroup per reseller, add their pubkey, grant write 3. All enforcement happens in OSIS lifecycle hooks — zero custom auth code in the application --- ### Implementation order 1. **Add OSchema types**: `AclRight`, `AclPrincipal`, `AclEntry`, `AclGroup` to `schemas/base/acl.oschema` 2. **Add `owner_pubkey` + `acl` fields to Context** in `schemas/base/core.oschema` 3. **Regenerate types** — auto-generates Rust structs + CRUD for AclGroup 4. **Add `AclService`** with `check_access`, `grant`, `revoke`, `resolve_groups` 5. **Update lifecycle hook signatures** to receive `RequestContext` (blocked on hero_rpc#11) 6. **Wire ACL checks into hooks** for all domains 7. **Update znzfreezone** to use OSIS ACL instead of custom middleware
timur changed title from Add ACL fields to Context type and wire into lifecycle hooks to [Phase 3] Add ACL fields to Context type and wire into lifecycle hooks 2026-03-26 13:04:40 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lhumina_code/hero_osis#16
No description provided.