refactor(ownership): collapse OwnerId+Identity into UserId with role variants - #2677
Conversation
Promote Staging to Main
…variants
- Expand UserRole to {Owner, Admin, Regular}
- UserId carries role; methods is_owner()/is_admin()/is_regular()
- Remove From<String>/From<&str> impls (enforces types.md rule)
- Validated construction via new(); from_trusted() for DB-sourced values
Addresses bug pattern from #2561, #2620, #2349 where owner_id silently
round-tripped as String.
There was a problem hiding this comment.
Pull request overview
Refactors the ownership/identity model by collapsing OwnerId + Identity into a single validated UserId that carries both the user id string and an expanded UserRole (Owner, Admin, Regular). This propagates through tenant scopes, pairing flows, and channel integrations to reduce stringly-typed ID handling.
Changes:
- Introduces
UserId(validated constructor + explicitfrom_trusted) and expandsUserRolewith centralized role parsing (from_db_role) and predicates. - Migrates tenant/admin scope constructors and role gating to use
UserId+is_admin()(Owner or Admin). - Updates pairing resolution/caching + multiple integration tests and channel entrypoints to pass/return
UserId.
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/pairing_integration.rs | Updates pairing integration tests to use UserId/UserRole. |
| tests/ownership_integration.rs | Updates ownership integration tests; adds new UserId/role behavior assertions. |
| src/tenant.rs | Replaces stored identity type with UserId; updates admin gating to use is_admin(). |
| src/pairing/store.rs | Pairing store APIs now return/accept UserId and cache it. |
| src/ownership/mod.rs | Introduces UserId/UserRole (expanded role tiers, validation, predicates); removes old types. |
| src/ownership/cache.rs | Cache now stores UserId and evicts using UserId::as_str(). |
| src/extensions/manager.rs | Updates pairing lookups to pass UserId. |
| src/db/postgres.rs | Returns UserId from pairing identity resolution; role parsed via from_db_role. |
| src/db/mod.rs | Updates ChannelPairingStore trait signature to return UserId. |
| src/db/libsql/pairing.rs | Returns UserId from pairing identity resolution; role parsed via from_db_role. |
| src/cli/pairing.rs | CLI pairing approval now passes UserId (Owner role) to store. |
| src/channels/web/server.rs | Web pairing approval handler now binds to authenticated user via UserId. |
| src/channels/web/auth.rs | Adds UserIdentity::is_admin() and uses it for admin route gating. |
| src/channels/wasm/wrapper.rs | WASM host pairing callback now uses UserId and extracts id via as_str(). |
| src/channels/wasm/setup.rs | WASM channel setup uses UserId (Owner) when resolving owner actor id. |
| src/channels/signal.rs | Signal pairing resolution now uses UserId and extracts id via as_str(). |
| src/agent/dispatcher.rs | Switches admin policy check to UserId::is_admin(). |
| src/agent/agent_loop.rs | Tenant context creation now uses UserId bridge construction. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // CLI operates as the deployment owner (from config). | ||
| let owner_id = crate::ownership::UserId::from_trusted( | ||
| config.owner_id.clone(), | ||
| crate::ownership::UserRole::Owner, | ||
| ); |
There was a problem hiding this comment.
This code treats the CLI identity as UserRole::Owner, but the earlier get_or_create_user upsert still writes role: "admin". That means any later DB-sourced role checks will see Admin rather than Owner. Consider persisting role: "owner" here (or using UserRole::Owner.as_db_role()) for consistency.
There was a problem hiding this comment.
Fixed in 7b01366. Added UserRole::as_db_role() and the CLI now upserts role:"owner" to match the in-memory identity.
| /// Construct an `AdminScope`. Returns `None` unless the identity carries | ||
| /// admin privileges (`UserRole::Admin` or `UserRole::Owner`). | ||
| pub fn new(identity: crate::ownership::UserId, db: Arc<dyn Database>) -> Option<Self> { | ||
| if !identity.is_admin() { |
There was a problem hiding this comment.
In this block, AdminScope is now gated by identity.is_admin() (Admin or Owner), but the nearby section header / doc comment still says it “requires UserRole::Admin” / “Constructable only with UserRole::Admin”. Please update those comments to avoid misleading future readers.
There was a problem hiding this comment.
Fixed in 7b01366. AdminScope doc now reflects that Owner also passes is_admin().
| .ok_or((StatusCode::UNAUTHORIZED, "Not authenticated"))?; | ||
| if identity.role != "admin" { | ||
| if !identity.is_admin() { | ||
| return Err((StatusCode::FORBIDDEN, "Admin role required")); |
There was a problem hiding this comment.
The rejection message still says "Admin role required", but is_admin() now also permits owner. Consider updating the message to something like “Admin privileges required” (admin or owner) to match the new authorization rule.
| return Err((StatusCode::FORBIDDEN, "Admin role required")); | |
| return Err(( | |
| StatusCode::FORBIDDEN, | |
| "Admin privileges required (admin or owner)", | |
| )); |
There was a problem hiding this comment.
Fixed in 7b01366. Applied the suggested wording.
There was a problem hiding this comment.
Code Review
This pull request refactors the ownership model by consolidating the previous OwnerId and Identity types into a single UserId struct that encapsulates both the user identifier and their UserRole. The role system is expanded to include Owner, Admin, and Regular variants, with updated logic to treat owners as administrators. These changes are propagated across the agent loop, database backends, pairing store, and tenant-scoped execution contexts. Feedback suggests that UserId should implement PartialEq and Hash based solely on the id string to prevent identity conflicts when roles vary, and that the role field should be required during deserialization to maintain data integrity.
| #[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] | ||
| pub struct UserId { | ||
| id: String, | ||
| #[serde(default = "UserRole::regular_default_if_missing")] | ||
| role: UserRole, | ||
| } |
There was a problem hiding this comment.
Including the 'role' in the derived 'PartialEq' and 'Hash' implementations for 'UserId' is a potential footgun. It means that two 'UserId' instances representing the same user (same 'id' string) will be considered unequal if their roles differ. This can lead to subtle bugs if 'UserId' is used as a key in a 'HashMap' or for identity comparisons. Consider implementing 'PartialEq' and 'Hash' manually to only compare the 'id' field. Additionally, avoid using '#[serde(default)]' for the 'role' field; as a critical field, it is safer for deserialization to fail if the data is missing rather than defaulting to a potentially incorrect role.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct UserId {
id: String,
role: UserRole,
}
impl PartialEq for UserId {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Eq for UserId {}
impl std::hash::Hash for UserId {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.id.hash(state);
}
}References
- Avoid using #[serde(default)] for critical fields in data structures deserialized from external sources to prevent silent failures and ensure data integrity.
There was a problem hiding this comment.
Good catch — fixed in 7b01366. UserId now manually implements PartialEq/Eq/Hash on id only; role is metadata. Added a regression test that builds a HashSet keyed on UserId and confirms cross-role membership.
…ith-role # Conflicts: # src/channels/web/server.rs
…ist owner role, doc fixes - UserId PartialEq/Eq/Hash now compare only `id`, not `role`. Role is metadata that travels with the identity; two UserIds with the same id but different roles must be interchangeable as HashMap/HashSet keys and cache lookup targets. Added a regression test that builds a HashSet keyed on UserId and asserts cross-role `.contains()` membership, plus a hash-equality check. - CLI pairing path now persists the "owner" role string (via UserRole::Owner.as_db_role()) instead of the hardcoded "admin", so a reload through UserRole::from_db_role stays Owner rather than being silently downgraded to Admin. - Update the feature/pairing approve handler to mirror the refactor: build UserId via from_trusted + UserRole::from_db_role(&user.role) instead of the removed OwnerId::from. - AdminScope doc comment now reflects that Owner also passes is_admin(). - AdminUser extractor error message now reads "Admin privileges required (admin or owner)" so the forbidden response matches the actual gate.
Brings staging's gateway modularization (ironclaw#2599 stages 4c + 4d + 5) and #2677 UserId collapse into the coding-agent UX branch: Conflicts resolved: - crates/ironclaw_common/src/lib.rs — combined GitHubRepo export with the new JobResultStatus export. - src/channels/web/types.rs — kept both the staging `attachments` field and the branch's `ChatSendMode`/`mode` field on SendMessageRequest. - src/channels/web/server.rs — accepted staging's thin shim; my chat handler deltas reapplied in the canonical `features/chat/mod.rs`. - src/channels/web/handlers/chat.rs — deleted per staging (content moved into `features/chat/mod.rs`); my changes reapplied there. - crates/ironclaw_gateway/static/app.js + style.css — deleted per staging (split into per-surface modules). ProjectUI IIFE migrated to `js/surfaces/projects.js`; `!` prefix detection added in `js/surfaces/chat.js::sendMessage`; shell SSE handlers in `js/core/sse.js`; shell turn + history-replay class in `js/core/history.js`; `copyMessage` fallback in `js/core/render.js`. Project chrome + shell-turn CSS appended to `styles/surfaces/projects.css`; `history-replay` suppression added in `styles/surfaces/chat.css`. types.md applied to the coding-agent surface: - `bridge::update_engine_project` now takes `ironclaw_engine::ProjectId` rather than `&str` — internal calls flow the typed id, eliminating the class of bug types.md prevents. - `bridge::set_conversation_project` takes `Option<ProjectId>` for the same reason. - `project_admin` tools parse raw UUIDs into `ProjectId` at the tool boundary via the new `parse_project_id` helper, so every downstream call site is typed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…variants (nearai#2677) * refactor(ownership): collapse OwnerId+Identity into UserId with role variants - Expand UserRole to {Owner, Admin, Regular} - UserId carries role; methods is_owner()/is_admin()/is_regular() - Remove From<String>/From<&str> impls (enforces types.md rule) - Validated construction via new(); from_trusted() for DB-sourced values Addresses bug pattern from nearai#2561, nearai#2620, nearai#2349 where owner_id silently round-tripped as String. * refactor(ownership): address review feedback — id-only equality, persist owner role, doc fixes - UserId PartialEq/Eq/Hash now compare only `id`, not `role`. Role is metadata that travels with the identity; two UserIds with the same id but different roles must be interchangeable as HashMap/HashSet keys and cache lookup targets. Added a regression test that builds a HashSet keyed on UserId and asserts cross-role `.contains()` membership, plus a hash-equality check. - CLI pairing path now persists the "owner" role string (via UserRole::Owner.as_db_role()) instead of the hardcoded "admin", so a reload through UserRole::from_db_role stays Owner rather than being silently downgraded to Admin. - Update the feature/pairing approve handler to mirror the refactor: build UserId via from_trusted + UserRole::from_db_role(&user.role) instead of the removed OwnerId::from. - AdminScope doc comment now reflects that Owner also passes is_admin(). - AdminUser extractor error message now reads "Admin privileges required (admin or owner)" so the forbidden response matches the actual gate. --------- Co-authored-by: Henry Park <henrypark133@gmail.com>
Summary
PR 1 of 4 type-safety refactors. Enforces the
.claude/rules/types.mdrule that identifiers must be newtypes with validation at construction and no silentFrom<&str>/From<String>conversions.What changed
OwnerId+Identityinto a singleUserIdcarrying both the id and its role. Scope constructors (TenantScope::with_identity,AdminScope::new,TenantCtx::new) now take one argument where they previously took two entangled ones.UserRolefrom{Admin, Member}to{Owner, Admin, Regular}.Owneris a super-admin tier (satisfiesis_admin()too). Legacy"member"DB rows keep deserializing transparently viaUserRole::from_db_rolewhich maps unknowns toRegular.From<String>andFrom<&str>onUserId— the whole point of the rule. Callers now pick explicitly between:UserId::new(id, role)?— validated (rejects empty / whitespace-only)UserId::from_trusted(id, role)— opt-out for DB/config-sourced values (the choice itself is the audit trail)UserId::try_from((String, UserRole))— validating tuple formDeref<Target = str>. The boundary is visible at every call site:.as_str()/.role()/.is_admin().UserId:is_owner(),is_admin()(covers Owner + Admin),is_regular(). These delegate to the matchingUserRolemethods so the rule stays centralized.AdminScope::newto gate onidentity.is_admin()rather than== UserRole::Admin, soOwnerusers automatically clear admin gates.UserIdentity::is_admin()on the web-auth struct (also routes throughUserRole::from_db_role) instead of comparingrole == "admin"raw.Why now
Fixes the bug pattern behind #2561, #2620, #2349, #2126 — each of those shipped because a stringly-typed
owner_idround-tripped through more than one layer with no compile-time discipline.UserIdwith explicit construction makes each of those a type error at the next regression.Scope note
This PR migrates
src/ownership/,src/agent/,src/channels/(except engine-v2 DB row structs),src/bridge/,src/cli/pairing.rs,src/db/,src/extensions/,src/tenant.rs,src/pairing/, and thetests/ownership_integration.rs+tests/pairing_integration.rstiers. Rawowner_id: Stringfields on DB row structs and wire DTOs are left as-is pertypes.mdboundary rules. The engine-crateOwnerId<'a>enum (a shared/user sum type insidecrates/ironclaw_engine/src/types/mod.rs) is a distinct type and out of scope.Test plan
cargo check --all-features— cleancargo clippy --all --benches --tests --examples --all-features— zero warningscargo test -p ironclaw_common— 23 passedcargo test --lib ownership::tests— 15 passed (new validation/role tests included)cargo test --lib tenant::tests— 12 passed (includes owner-gets-admin-scope regression test)cargo test --features integration— rerun with PostgreSQL available in CI