Skip to content

refactor(ownership): collapse OwnerId+Identity into UserId with role variants - #2677

Merged
ilblackdragon merged 4 commits into
stagingfrom
refactor/user-id-with-role
Apr 20, 2026
Merged

ilblackdragon merged 4 commits into
stagingfrom
refactor/user-id-with-role

Conversation

@ilblackdragon

Copy link
Copy Markdown
Member

Summary

PR 1 of 4 type-safety refactors. Enforces the .claude/rules/types.md rule that identifiers must be newtypes with validation at construction and no silent From<&str>/From<String> conversions.

What changed

  • Collapsed OwnerId + Identity into a single UserId carrying 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.
  • Expanded UserRole from {Admin, Member} to {Owner, Admin, Regular}. Owner is a super-admin tier (satisfies is_admin() too). Legacy "member" DB rows keep deserializing transparently via UserRole::from_db_role which maps unknowns to Regular.
  • Removed From<String> and From<&str> on UserId — 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 form
  • Removed Deref<Target = str>. The boundary is visible at every call site: .as_str() / .role() / .is_admin().
  • Three role predicates on UserId: is_owner(), is_admin() (covers Owner + Admin), is_regular(). These delegate to the matching UserRole methods so the rule stays centralized.
  • Updated AdminScope::new to gate on identity.is_admin() rather than == UserRole::Admin, so Owner users automatically clear admin gates.
  • Added UserIdentity::is_admin() on the web-auth struct (also routes through UserRole::from_db_role) instead of comparing role == "admin" raw.

Why now

Fixes the bug pattern behind #2561, #2620, #2349, #2126 — each of those shipped because a stringly-typed owner_id round-tripped through more than one layer with no compile-time discipline. UserId with 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 the tests/ownership_integration.rs + tests/pairing_integration.rs tiers. Raw owner_id: String fields on DB row structs and wire DTOs are left as-is per types.md boundary rules. The engine-crate OwnerId<'a> enum (a shared/user sum type inside crates/ironclaw_engine/src/types/mod.rs) is a distinct type and out of scope.

Test plan

  • cargo check --all-features — clean
  • cargo clippy --all --benches --tests --examples --all-features — zero warnings
  • cargo test -p ironclaw_common — 23 passed
  • cargo 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

henrypark133 and others added 2 commits April 17, 2026 17:59
…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.
Copilot AI review requested due to automatic review settings April 19, 2026 14:20
@github-actions github-actions Bot added scope: agent Agent core (agent loop, router, scheduler) scope: channel/cli TUI / CLI channel scope: channel/web Web gateway channel scope: channel/wasm WASM channel runtime scope: db Database trait / abstraction scope: db/postgres PostgreSQL backend scope: extensions Extension management scope: pairing Pairing mode size: XL 500+ changed lines risk: high Safety, secrets, auth, or critical infrastructure contributor: core 20+ merged PRs labels Apr 19, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 + explicit from_trusted) and expands UserRole with 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.

Comment thread src/cli/pairing.rs
Comment on lines +62 to +66
// CLI operates as the deployment owner (from config).
let owner_id = crate::ownership::UserId::from_trusted(
config.owner_id.clone(),
crate::ownership::UserRole::Owner,
);

Copilot AI Apr 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7b01366. Added UserRole::as_db_role() and the CLI now upserts role:"owner" to match the in-memory identity.

Comment thread src/tenant.rs
Comment on lines +939 to +942
/// 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() {

Copilot AI Apr 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7b01366. AdminScope doc now reflects that Owner also passes is_admin().

Comment thread src/channels/web/auth.rs Outdated
.ok_or((StatusCode::UNAUTHORIZED, "Not authenticated"))?;
if identity.role != "admin" {
if !identity.is_admin() {
return Err((StatusCode::FORBIDDEN, "Admin role required"));

Copilot AI Apr 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
return Err((StatusCode::FORBIDDEN, "Admin role required"));
return Err((
StatusCode::FORBIDDEN,
"Admin privileges required (admin or owner)",
));

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7b01366. Applied the suggested wording.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/ownership/mod.rs Outdated
Comment on lines +107 to +112
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct UserId {
id: String,
#[serde(default = "UserRole::regular_default_if_missing")]
role: UserRole,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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
  1. Avoid using #[serde(default)] for critical fields in data structures deserialized from external sources to prevent silent failures and ensure data integrity.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@github-actions github-actions Bot added risk: medium Business logic, config, or moderate-risk modules and removed risk: high Safety, secrets, auth, or critical infrastructure labels Apr 20, 2026
@ilblackdragon
ilblackdragon merged commit fb4fc82 into staging Apr 20, 2026
18 checks passed
@ilblackdragon
ilblackdragon deleted the refactor/user-id-with-role branch April 20, 2026 03:31
ilblackdragon added a commit that referenced this pull request Apr 20, 2026
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>
This was referenced Apr 22, 2026
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: medium Business logic, config, or moderate-risk modules scope: agent Agent core (agent loop, router, scheduler) scope: channel/cli TUI / CLI channel scope: channel/wasm WASM channel runtime scope: channel/web Web gateway channel scope: db/postgres PostgreSQL backend scope: db Database trait / abstraction scope: extensions Extension management scope: pairing Pairing mode size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants