Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/ironclaw_host_api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ pub mod ingress;
pub mod mount;
pub mod path;
pub mod resource;
pub mod role;
pub mod runtime;
pub mod runtime_policy;
pub mod scope;
Expand All @@ -70,6 +71,7 @@ pub use ingress::*;
pub use mount::*;
pub use path::*;
pub use resource::*;
pub use role::*;
pub use runtime::*;
pub use runtime_policy::*;
pub use scope::*;
Expand Down
193 changes: 193 additions & 0 deletions crates/ironclaw_host_api/src/role.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
//! User authority vocabulary: [`UserRole`] and [`UserStatus`].
//!
//! Shared identity enums (alongside `UserId` / `TenantId`) so any crate that
//! has a `UserId` can reason about a user's role without depending on the
//! identity store. Wire-stable snake_case; the string forms match the
//! `users.role` / `users.status` columns and must not drift
//! (see `.claude/rules/types.md`).

use serde::{Deserialize, Serialize};

/// Role a user holds on its tenant. Privilege order, highest first:
/// `Owner > Admin > Member`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UserRole {
/// Deployment/tenant owner — implies admin.
Owner,
/// Administrative privileges (user + capability management).
Admin,
/// Ordinary user. Least-privilege default for unknown/missing values.
#[default]
Member,
Comment on lines +21 to +22
}

impl UserRole {
/// Stable wire/DB string (matches the serde representation).
pub fn as_str(self) -> &'static str {
match self {
Self::Owner => "owner",
Self::Admin => "admin",
Self::Member => "member",
}
}

/// Parse a persisted role. Unknown or missing values fall back to the
/// least-privilege [`UserRole::Member`] — never escalate on a bad value.
pub fn parse(value: &str) -> Self {
match value.trim().to_ascii_lowercase().as_str() {
"owner" => Self::Owner,
"admin" => Self::Admin,
_ => Self::Member,
}
}

/// `true` for roles with administrative privileges (`Owner` and `Admin`).
pub fn is_admin(self) -> bool {
matches!(self, Self::Owner | Self::Admin)
}

/// `true` only for the tenant owner.
pub fn is_owner(self) -> bool {
matches!(self, Self::Owner)
}

/// Privilege rank: `Owner` = 2, `Admin` = 1, `Member` = 0. Higher means
/// more privileged. Used for strict outranking comparisons; the enum
/// deliberately does not derive `Ord` because its variant declaration
/// order (Owner first) would invert the privilege order.
pub fn rank(self) -> u8 {
match self {
Self::Owner => 2,
Self::Admin => 1,
Self::Member => 0,
}
}

/// `true` when `self` is strictly more privileged than `other`. A role
/// never outranks itself, so callers can express "may only act on a
/// strictly lower role" without special-casing equality.
pub fn outranks(self, other: UserRole) -> bool {
self.rank() > other.rank()
}
}

/// Lifecycle status of a user. Persisted as the `users.status` column; the
/// string form matches the serde representation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UserStatus {
/// Normal, usable account. Default for unknown/missing values.
#[default]
Active,
/// Temporarily blocked by an admin; identity retained.
Suspended,
/// Deactivated; retained for audit, not usable.
Deactivated,
}
Comment on lines +79 to +87

impl UserStatus {
/// Stable wire/DB string (matches the serde representation).
pub fn as_str(self) -> &'static str {
match self {
Self::Active => "active",
Self::Suspended => "suspended",
Self::Deactivated => "deactivated",
}
}

/// Parse a persisted status. Unknown or missing values fall back to
/// [`UserStatus::Active`] (the DB-level default).
pub fn parse(value: &str) -> Self {
match value.trim().to_ascii_lowercase().as_str() {
"suspended" => Self::Suspended,
"deactivated" => Self::Deactivated,
_ => Self::Active,
}
}

/// `true` for a normal, usable account.
pub fn is_active(self) -> bool {
matches!(self, Self::Active)
}
}

#[cfg(test)]
mod tests {
use super::{UserRole, UserStatus};

#[test]
fn role_roundtrips_and_defaults_least_privilege() {
for role in [UserRole::Owner, UserRole::Admin, UserRole::Member] {
assert_eq!(UserRole::parse(role.as_str()), role);
}
assert_eq!(UserRole::parse("OWNER"), UserRole::Owner);
assert_eq!(UserRole::parse("nonsense"), UserRole::Member);
assert_eq!(UserRole::parse(""), UserRole::Member);
assert_eq!(UserRole::default(), UserRole::Member);
}

#[test]
fn is_admin_includes_owner() {
assert!(UserRole::Owner.is_admin());
assert!(UserRole::Admin.is_admin());
assert!(!UserRole::Member.is_admin());
assert!(UserRole::Owner.is_owner());
assert!(!UserRole::Admin.is_owner());
}

#[test]
fn rank_orders_owner_above_admin_above_member() {
assert_eq!(UserRole::Owner.rank(), 2);
assert_eq!(UserRole::Admin.rank(), 1);
assert_eq!(UserRole::Member.rank(), 0);
}

#[test]
fn outranks_is_strict_privilege_order() {
// Owner outranks Admin and Member.
assert!(UserRole::Owner.outranks(UserRole::Admin));
assert!(UserRole::Owner.outranks(UserRole::Member));

// Admin outranks Member only (not Owner, not a peer Admin).
assert!(UserRole::Admin.outranks(UserRole::Member));
assert!(!UserRole::Admin.outranks(UserRole::Owner));
assert!(!UserRole::Admin.outranks(UserRole::Admin));

// Member outranks nobody.
assert!(!UserRole::Member.outranks(UserRole::Owner));
assert!(!UserRole::Member.outranks(UserRole::Admin));
assert!(!UserRole::Member.outranks(UserRole::Member));

// No role outranks itself.
for role in [UserRole::Owner, UserRole::Admin, UserRole::Member] {
assert!(!role.outranks(role));
}
}

#[test]
fn status_roundtrips_and_defaults_active() {
for status in [
UserStatus::Active,
UserStatus::Suspended,
UserStatus::Deactivated,
] {
assert_eq!(UserStatus::parse(status.as_str()), status);
}
assert_eq!(UserStatus::parse("unknown"), UserStatus::Active);
assert_eq!(UserStatus::default(), UserStatus::Active);
assert!(UserStatus::Active.is_active());
}

#[test]
fn role_serde_is_snake_case() {
assert_eq!(
serde_json::to_string(&UserRole::Member).expect("serialize"),
"\"member\""
);
assert_eq!(
serde_json::from_str::<UserRole>("\"owner\"").expect("deserialize"),
UserRole::Owner
);
}
}
25 changes: 24 additions & 1 deletion crates/ironclaw_product_workflow/src/webui_inbound.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ pub struct WebUiAuthenticatedCaller {
pub project_id: Option<ProjectId>,
#[serde(default, skip_serializing_if = "is_false")]
pub operator_webui_config: bool,
/// Resolved role of the authenticated caller. Default is the
/// least-privilege `Member`; the env-bearer operator authenticates as
/// `Owner`. Carried so the facade can gate admin operations via
/// [`Self::is_admin`].
#[serde(default)]
pub role: ironclaw_host_api::UserRole,
Comment on lines +77 to +82

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

Since UserStatus is introduced in this PR to represent suspended or deactivated users, WebUiAuthenticatedCaller should also carry the user's status. This ensures that the authentication and authorization pipeline can gate operations for suspended/deactivated users, preventing a security gap where suspended users are treated as active.

Suggested change
/// Resolved role of the authenticated caller. Default is the
/// least-privilege `Member`; the env-bearer operator authenticates as
/// `Owner`. Carried so the facade can gate admin operations via
/// [`Self::is_admin`].
#[serde(default)]
pub role: ironclaw_host_api::UserRole,
/// Resolved role of the authenticated caller. Default is the
/// least-privilege `Member`; the env-bearer operator authenticates as
/// `Owner`. Carried so the facade can gate admin operations via
/// [`Self::is_admin`].
#[serde(default)]
pub role: ironclaw_host_api::UserRole,
/// Resolved status of the authenticated caller. Default is `Active`.
/// Carried so the facade can gate operations for suspended/deactivated users.
#[serde(default)]
pub status: ironclaw_host_api::UserStatus,

}

fn is_false(value: &bool) -> bool {
Expand All @@ -93,6 +99,7 @@ impl WebUiAuthenticatedCaller {
agent_id,
project_id,
operator_webui_config: false,
role: ironclaw_host_api::UserRole::Member,

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

Initialize the status field to Active in the WebUiAuthenticatedCaller::new constructor.

            role: ironclaw_host_api::UserRole::Member,
            status: ironclaw_host_api::UserStatus::Active,

}
}

Expand All @@ -101,8 +108,24 @@ impl WebUiAuthenticatedCaller {
self
}

/// Override the caller's role (the default from [`Self::new`] is the
/// least-privilege `Member`).
#[must_use]
pub fn with_role(mut self, role: ironclaw_host_api::UserRole) -> Self {
self.role = role;
self
}
Comment on lines +111 to +117

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

Add with_status and is_active helper methods to WebUiAuthenticatedCaller to support setting and checking the user's status.

Suggested change
/// Override the caller's role (the default from [`Self::new`] is the
/// least-privilege `Member`).
#[must_use]
pub fn with_role(mut self, role: ironclaw_host_api::UserRole) -> Self {
self.role = role;
self
}
/// Override the caller's role (the default from [`Self::new`] is the
/// least-privilege `Member`).
#[must_use]
pub fn with_role(mut self, role: ironclaw_host_api::UserRole) -> Self {
self.role = role;
self
}
/// Override the caller's status (the default from [`Self::new`] is `Active`).
#[must_use]
pub fn with_status(mut self, status: ironclaw_host_api::UserStatus) -> Self {
self.status = status;
self
}
/// `true` when the caller's account is active and usable.
pub fn is_active(&self) -> bool {
self.status.is_active()
}


/// `true` when the caller may perform admin operations: either a role with
/// admin privileges (`Owner` / `Admin`), or the legacy operator-config
/// bearer (kept as a compat shim while per-user role population rolls out
/// across the authenticators).
pub fn is_admin(&self) -> bool {
self.role.is_admin() || self.operator_webui_config
}

pub fn actor(&self) -> TurnActor {
TurnActor::new(self.user_id.clone())
TurnActor::new(self.user_id.clone()).with_role(self.role)
}

pub fn turn_scope(&self, thread_id: ThreadId) -> TurnScope {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1962,6 +1962,7 @@ mod tests {
agent_id: None,
project_id: None,
operator_webui_config: true,
role: ironclaw_host_api::UserRole::Member,

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

Initialize the status field in the test's WebUiAuthenticatedCaller struct literal.

            role: ironclaw_host_api::UserRole::Member,
            status: ironclaw_host_api::UserStatus::Active,

}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -816,6 +816,7 @@ fn request_for_caller(method: &str, body: &str, tenant_id: &str, user_id: &str)
agent_id: None,
project_id: None,
operator_webui_config: true,
role: ironclaw_host_api::UserRole::Member,

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

Initialize the status field in the test's WebUiAuthenticatedCaller struct literal.

            role: ironclaw_host_api::UserRole::Member,
            status: ironclaw_host_api::UserStatus::Active,

});
if method == "GET" {
builder = builder.header("content-length", "0");
Expand Down
4 changes: 4 additions & 0 deletions crates/ironclaw_reborn_composition/src/slack_host_beta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1508,6 +1508,7 @@ mod tests {
agent_id: Some(AgentId::new(AGENT).expect("agent")),
project_id: Some(ProjectId::new(PROJECT).expect("project")),
operator_webui_config: false,
role: ironclaw_host_api::UserRole::Member,

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

Initialize the status field in the test's WebUiAuthenticatedCaller struct literal.

                        role: ironclaw_host_api::UserRole::Member,
                        status: ironclaw_host_api::UserStatus::Active,

})
.body(Body::from(redeem_body))
.expect("redeem request builds"),
Expand Down Expand Up @@ -1673,6 +1674,7 @@ mod tests {
agent_id: Some(AgentId::new(AGENT).expect("agent")),
project_id: Some(ProjectId::new(PROJECT).expect("project")),
operator_webui_config: true,
role: ironclaw_host_api::UserRole::Member,

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

Initialize the status field in the test's WebUiAuthenticatedCaller struct literal.

                        role: ironclaw_host_api::UserRole::Member,
                        status: ironclaw_host_api::UserStatus::Active,

})
.body(Body::from(format!(
r#"{{"channel_id":"C0HOST","subject_user_id":"{SHARED_SUBJECT}"}}"#
Expand Down Expand Up @@ -2770,6 +2772,7 @@ mod tests {
agent_id: Some(AgentId::new(AGENT).expect("agent")),
project_id: Some(ProjectId::new(PROJECT).expect("project")),
operator_webui_config: false,
role: ironclaw_host_api::UserRole::Member,

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

Initialize the status field in the test's WebUiAuthenticatedCaller struct literal.

                        role: ironclaw_host_api::UserRole::Member,
                        status: ironclaw_host_api::UserStatus::Active,

})
.body(Body::from(redeem_body))
.expect("redeem request builds"),
Expand Down Expand Up @@ -3016,6 +3019,7 @@ mod tests {
agent_id: Some(AgentId::new(AGENT).expect("agent")),
project_id: Some(ProjectId::new(PROJECT).expect("project")),
operator_webui_config: false,
role: ironclaw_host_api::UserRole::Member,

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

Initialize the status field in the test's WebUiAuthenticatedCaller struct literal.

                        role: ironclaw_host_api::UserRole::Member,
                        status: ironclaw_host_api::UserStatus::Active,

})
.body(Body::from(redeem_body))
.expect("redeem request builds"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@ mod tests {
agent_id: None,
project_id: None,
operator_webui_config: false,
role: ironclaw_host_api::UserRole::Member,

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

Initialize the status field in the test's WebUiAuthenticatedCaller struct literal.

                role: ironclaw_host_api::UserRole::Member,
                status: ironclaw_host_api::UserStatus::Active,

})
.body(Body::from(body))
.unwrap()
Expand Down
18 changes: 17 additions & 1 deletion crates/ironclaw_reborn_composition/src/webui_serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,16 +145,30 @@ pub trait WebuiAuthenticator: Send + Sync + 'static {
pub struct WebuiAuthentication {
pub user_id: UserId,
pub capabilities: WebUiV2Capabilities,
/// Resolved role of the authenticated caller. Defaults to the
/// least-privilege `Member`; the env-bearer operator authenticates as
/// `Owner`. Session/OIDC authenticators populate this from the persisted
/// user record once per-user role reads land.
pub role: ironclaw_reborn_identity::UserRole,
Comment on lines +148 to +152

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

Add the status field to WebuiAuthentication to carry the user's status through the authentication layer.

    /// Resolved role of the authenticated caller. Defaults to the
    /// least-privilege `Member`; the env-bearer operator authenticates as
    /// `Owner`. Session/OIDC authenticators populate this from the persisted
    /// user record once per-user role reads land.
    pub role: ironclaw_reborn_identity::UserRole,
    /// Resolved status of the authenticated caller. Defaults to `Active`.
    pub status: ironclaw_reborn_identity::UserStatus,

}

impl WebuiAuthentication {
pub fn new(user_id: UserId, capabilities: WebUiV2Capabilities) -> Self {
Self {
user_id,
capabilities,
role: ironclaw_reborn_identity::UserRole::Member,
}
Comment on lines +160 to 161

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

Initialize the status field to Active in WebuiAuthentication::new.

            role: ironclaw_reborn_identity::UserRole::Member,
            status: ironclaw_reborn_identity::UserStatus::Active,
        }

}

/// Override the caller's role (the default from [`Self::new`] is the
/// least-privilege `Member`).
#[must_use]
pub fn with_role(mut self, role: ironclaw_reborn_identity::UserRole) -> Self {
self.role = role;
self
}
Comment on lines +164 to +170

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

Add the with_status helper method to WebuiAuthentication.

    /// Override the caller's role (the default from [`Self::new`] is the
    /// least-privilege `Member`).
    #[must_use]
    pub fn with_role(mut self, role: ironclaw_reborn_identity::UserRole) -> Self {
        self.role = role;
        self
    }

    /// Override the caller's status (the default from [`Self::new`] is `Active`).
    #[must_use]
    pub fn with_status(mut self, status: ironclaw_reborn_identity::UserStatus) -> Self {
        self.status = status;
        self
    }


pub fn user(user_id: UserId) -> Self {
Self::new(user_id, WebUiV2Capabilities::default())
}
Expand All @@ -166,6 +180,7 @@ impl WebuiAuthentication {
operator_webui_config: true,
},
)
.with_role(ironclaw_reborn_identity::UserRole::Owner)
}
}

Expand Down Expand Up @@ -907,7 +922,8 @@ async fn authenticate_request(
state.default_agent_id.clone(),
state.default_project_id.clone(),
)
.with_operator_webui_config(auth.capabilities.operator_webui_config);
.with_operator_webui_config(auth.capabilities.operator_webui_config)
.with_role(auth.role);
Comment on lines +925 to +926

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

Propagate the authenticated user's status from WebuiAuthentication to WebUiAuthenticatedCaller.

    .with_operator_webui_config(auth.capabilities.operator_webui_config)
    .with_role(auth.role)
    .with_status(auth.status);

request.extensions_mut().insert(caller);
request.extensions_mut().insert(auth.capabilities);
#[cfg(feature = "openai-compat-beta")]
Expand Down
2 changes: 2 additions & 0 deletions crates/ironclaw_reborn_identity/src/filesystem_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,8 @@ where
&StoredUser {
email: identity.email.clone(),
display_name: identity.display_name.clone(),
role: crate::UserRole::default(),
status: crate::UserStatus::default(),
created_at: now.clone(),
updated_at: now.clone(),
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
pub(super) struct StoredUser {
pub(super) email: Option<String>,
pub(super) display_name: Option<String>,
#[serde(default)]
pub(super) role: crate::UserRole,
#[serde(default)]
pub(super) status: crate::UserStatus,
pub(super) created_at: String,
pub(super) updated_at: String,
}
Expand Down
Loading
Loading