From 7cc2183bd52b40c5d72458152754669317a9b0df Mon Sep 17 00:00:00 2001 From: GhostFrame Date: Sat, 1 Aug 2026 13:01:10 -0400 Subject: [PATCH] feat(cli): add administrator account controls --- README.md | 17 ++ crates/frameshift-cli/src/cmd/account.rs | 286 +++++++++++++++++++++- crates/frameshift-client/src/account.rs | 171 ++++++++++++- docs/wiki/CLI-Reference.md | 13 + docs/wiki/Operations-and-Observability.md | 9 + 5 files changed, 485 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 5e626ac..9a89cf4 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,13 @@ frameshift account status # Revoke the provider session, then erase the exact local credential and metadata. frameshift account logout + +# Grant or revoke one global platform role as an administrator. +frameshift account grant-role --server --account-id --role +frameshift account revoke-role --server --account-id --role + +# Set an account lifecycle state as an administrator. +frameshift account set-status --server --account-id --status ``` Deployments that register a different public OAuth client set @@ -137,6 +144,10 @@ callback. Login never accepts a bearer token through arguments, environment variables, or command-line values. First-party credentials require an interactive terminal and are read through hidden prompts. +Administrator account controls resolve bearer authority for the exact registry +without accepting a token argument. The registry rejects non-administrators and +prevents revoking, suspending, or disabling the last active administrator. + ## Automate mode Automate mode lets a host integration pick the persona for you. Frameshift classifies the task, ranks installed personas against project context, and stores the mode, sensitivity, lock, preferences, and transition audit. A session hook or other host integration decides when to run selection and activate a result; `frameshift automate on` does not switch personas by itself. @@ -432,6 +443,12 @@ frameshift diff Seman frameshift render Render persona source to markdown frameshift verify (--persona | --bundle ) Run conformance checks (exactly one of the two) [--runner mock|cli] [--model ] [--threshold <0.0-1.0>] +frameshift account grant-role --server --account-id Grant a global platform role + --role +frameshift account revoke-role --server --account-id Revoke a global platform role + --role +frameshift account set-status --server --account-id Set an account lifecycle state + --status frameshift register --server --handle [--display-name ] Claim an author handle frameshift publish --persona [--out ] Build a persona pack (add --server + --handle to sign and upload) [--server --handle ] diff --git a/crates/frameshift-cli/src/cmd/account.rs b/crates/frameshift-cli/src/cmd/account.rs index df3e002..0084ba2 100644 --- a/crates/frameshift-cli/src/cmd/account.rs +++ b/crates/frameshift-cli/src/cmd/account.rs @@ -1,17 +1,19 @@ -//! Interactive account registration, login, status, and logout commands. +//! Account session and administrator account-control commands. //! //! OIDC login uses the system browser and a loopback Authorization Code callback -//! with S256 PKCE. First-party credentials use hidden terminal prompts. No -//! password, invitation, or token is accepted through arguments or environment. +//! with S256 PKCE. First-party credentials use hidden terminal prompts. Session +//! commands accept no password, invitation, or bearer-token argument. use std::io::{IsTerminal as _, Read as _, Write as _}; use std::net::{IpAddr, SocketAddr, TcpListener, TcpStream}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use clap::{Args, Subcommand}; +use clap::{Args, Subcommand, ValueEnum}; +use frameshift_catalog::{AccountStatus, PlatformRole}; use frameshift_client::account::{ - get_account, get_auth_config, login_local_account, logout_local_account, - register_local_account, AccountView, LocalAccountSession, NativeAuthClient, + assign_account_platform_role, get_account, get_auth_config, login_local_account, + logout_local_account, register_local_account, revoke_account_platform_role, set_account_status, + AccountView, LocalAccountSession, NativeAuthClient, }; use frameshift_client::session::{AuthenticatedSession, SessionClient, SessionClientConfig}; use frameshift_client::session_store::{ @@ -20,7 +22,9 @@ use frameshift_client::session_store::{ use frameshift_client::{registry_base_url, Client, ClientError}; use secrecy::{ExposeSecret as _, SecretString}; use url::{Position, Url}; +use uuid::Uuid; +use crate::cmd::keys::resolve_access_token; use crate::util::{validate_server_url, CliError}; /// Default public OAuth client identifier for the shipped CLI. @@ -37,12 +41,12 @@ const REFRESH_MARGIN_SECS: u64 = 30; /// Arguments for the `account` command group. #[derive(Debug, Args)] pub struct AccountArgs { - /// Account session operation. + /// Account session or administrator operation. #[command(subcommand)] pub command: AccountCommand, } -/// Account session operations. +/// Account session and administrator operations. #[derive(Debug, Subcommand)] pub enum AccountCommand { /// Authenticate through an advertised provider and save the session securely. @@ -53,6 +57,85 @@ pub enum AccountCommand { Status, /// Revoke the provider session when supported and erase local credentials. Logout, + /// Grant one global platform role under administrator authority. + GrantRole { + /// Registry API base URL. + #[arg(long)] + server: String, + /// Stable target account UUID. + #[arg(long)] + account_id: Uuid, + /// Global platform role to grant. + #[arg(long, value_enum)] + role: PlatformRoleArg, + }, + /// Revoke one global platform role under administrator authority. + RevokeRole { + /// Registry API base URL. + #[arg(long)] + server: String, + /// Stable target account UUID. + #[arg(long)] + account_id: Uuid, + /// Global platform role to revoke. + #[arg(long, value_enum)] + role: PlatformRoleArg, + }, + /// Set one account lifecycle state under administrator authority. + SetStatus { + /// Registry API base URL. + #[arg(long)] + server: String, + /// Stable target account UUID. + #[arg(long)] + account_id: Uuid, + /// Account lifecycle state to apply. + #[arg(long, value_enum)] + status: AccountStatusArg, + }, +} + +/// CLI spelling for global platform roles. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum PlatformRoleArg { + /// Authority to review publication submissions. + Moderator, + /// Authority to administer platform and publication controls. + Administrator, +} + +/// Convert a CLI platform role into the shared wire enum. +impl From for PlatformRole { + /// Preserve the selected authority exactly. + fn from(value: PlatformRoleArg) -> Self { + match value { + PlatformRoleArg::Moderator => Self::Moderator, + PlatformRoleArg::Administrator => Self::Administrator, + } + } +} + +/// CLI spelling for account lifecycle states. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum AccountStatusArg { + /// Allow the account to authenticate and use assigned authority. + Active, + /// Temporarily deny account access while retaining history. + Suspended, + /// Permanently disable the account while retaining history. + Disabled, +} + +/// Convert a CLI account state into the shared wire enum. +impl From for AccountStatus { + /// Preserve the selected lifecycle state exactly. + fn from(value: AccountStatusArg) -> Self { + match value { + AccountStatusArg::Active => Self::Active, + AccountStatusArg::Suspended => Self::Suspended, + AccountStatusArg::Disabled => Self::Disabled, + } + } } /// Account login options. @@ -109,16 +192,61 @@ struct PendingCallback { stream: TcpStream, } -/// Execute one account session operation. +/// Execute one account session or administrator operation. pub fn run_account(args: AccountArgs) -> Result<(), CliError> { match args.command { AccountCommand::Login(args) => run_login(args), AccountCommand::Register(args) => run_register(args), AccountCommand::Status => run_status(), AccountCommand::Logout => run_logout(), + AccountCommand::GrantRole { + server, + account_id, + role, + } => grant_role(&server, account_id, role), + AccountCommand::RevokeRole { + server, + account_id, + role, + } => revoke_role(&server, account_id, role), + AccountCommand::SetStatus { + server, + account_id, + status, + } => set_status(&server, account_id, status), } } +/// Grant one role through the exact authenticated registry session. +fn grant_role(server: &str, account_id: Uuid, role: PlatformRoleArg) -> Result<(), CliError> { + validate_server_url(server)?; + let token = resolve_access_token(server)?; + let record = assign_account_platform_role(server, &token, account_id, role.into()) + .map_err(|error| CliError::Account(error.to_string()))?; + println!("{}", serde_json::to_string_pretty(&record)?); + Ok(()) +} + +/// Revoke one role through the exact authenticated registry session. +fn revoke_role(server: &str, account_id: Uuid, role: PlatformRoleArg) -> Result<(), CliError> { + validate_server_url(server)?; + let token = resolve_access_token(server)?; + let record = revoke_account_platform_role(server, &token, account_id, role.into()) + .map_err(|error| CliError::Account(error.to_string()))?; + println!("{}", serde_json::to_string_pretty(&record)?); + Ok(()) +} + +/// Transition one account through the exact authenticated registry session. +fn set_status(server: &str, account_id: Uuid, status: AccountStatusArg) -> Result<(), CliError> { + validate_server_url(server)?; + let token = resolve_access_token(server)?; + let account = set_account_status(server, &token, account_id, status.into()) + .map_err(|error| CliError::Account(error.to_string()))?; + println!("{}", serde_json::to_string_pretty(&account)?); + Ok(()) +} + /// Authenticate through the selected provider and persist the resulting session. fn run_login(args: AccountLoginArgs) -> Result<(), CliError> { let server = args.server.clone().unwrap_or_else(registry_base_url); @@ -791,6 +919,146 @@ mod tests { .is_err()); } + /// Administrator role grant parsing preserves the exact target and closed role value. + #[test] + fn parses_administrator_role_grant() { + let parsed = TestCli::try_parse_from([ + "frameshift", + "account", + "grant-role", + "--server", + "https://registry.example", + "--account-id", + "00000000-0000-0000-0000-000000000001", + "--role", + "administrator", + ]) + .expect("role grant arguments"); + let TestCommand::Account(AccountArgs { + command: + AccountCommand::GrantRole { + server, + account_id, + role, + }, + }) = parsed.command + else { + panic!("expected administrator role grant"); + }; + assert_eq!(server, "https://registry.example"); + assert_eq!(account_id, Uuid::from_u128(1)); + assert_eq!(role, PlatformRoleArg::Administrator); + } + + /// Administrator role revocation parsing preserves the exact target and role value. + #[test] + fn parses_administrator_role_revocation() { + let parsed = TestCli::try_parse_from([ + "frameshift", + "account", + "revoke-role", + "--server", + "https://registry.example", + "--account-id", + "00000000-0000-0000-0000-000000000001", + "--role", + "moderator", + ]) + .expect("role revocation arguments"); + let TestCommand::Account(AccountArgs { + command: + AccountCommand::RevokeRole { + server, + account_id, + role, + }, + }) = parsed.command + else { + panic!("expected administrator role revocation"); + }; + assert_eq!(server, "https://registry.example"); + assert_eq!(account_id, Uuid::from_u128(1)); + assert_eq!(role, PlatformRoleArg::Moderator); + } + + /// Administrator status parsing accepts the closed suspended state. + #[test] + fn parses_administrator_account_status_transition() { + let parsed = TestCli::try_parse_from([ + "frameshift", + "account", + "set-status", + "--server", + "https://registry.example", + "--account-id", + "00000000-0000-0000-0000-000000000001", + "--status", + "suspended", + ]) + .expect("account status arguments"); + let TestCommand::Account(AccountArgs { + command: + AccountCommand::SetStatus { + server, + account_id, + status, + }, + }) = parsed.command + else { + panic!("expected administrator account status transition"); + }; + assert_eq!(server, "https://registry.example"); + assert_eq!(account_id, Uuid::from_u128(1)); + assert_eq!(status, AccountStatusArg::Suspended); + } + + /// Administrator role commands reject publisher roles outside the closed platform set. + #[test] + fn administrator_role_commands_reject_publisher_roles() { + let result = TestCli::try_parse_from([ + "frameshift", + "account", + "revoke-role", + "--server", + "https://registry.example", + "--account-id", + "00000000-0000-0000-0000-000000000001", + "--role", + "owner", + ]); + assert!(result.is_err()); + } + + /// Every CLI platform-role value maps to its exact shared wire value. + #[test] + fn maps_all_administrator_platform_roles() { + assert_eq!( + PlatformRole::from(PlatformRoleArg::Moderator), + PlatformRole::Moderator + ); + assert_eq!( + PlatformRole::from(PlatformRoleArg::Administrator), + PlatformRole::Administrator + ); + } + + /// Every CLI account-status value maps to its exact shared wire value. + #[test] + fn maps_all_administrator_account_statuses() { + assert_eq!( + AccountStatus::from(AccountStatusArg::Active), + AccountStatus::Active + ); + assert_eq!( + AccountStatus::from(AccountStatusArg::Suspended), + AccountStatus::Suspended + ); + assert_eq!( + AccountStatus::from(AccountStatusArg::Disabled), + AccountStatus::Disabled + ); + } + /// Registry matching ignores a cosmetic trailing slash only. #[test] fn normalizes_registry_trailing_slash_without_weakening_base_binding() { diff --git a/crates/frameshift-client/src/account.rs b/crates/frameshift-client/src/account.rs index 3e3d7e9..c7ea916 100644 --- a/crates/frameshift-client/src/account.rs +++ b/crates/frameshift-client/src/account.rs @@ -1,12 +1,16 @@ -//! Bearer-authenticated account profile operations. +//! Account authentication, profile, and administrator control operations. //! //! Access tokens enter only through [`SecretString`] and are attached solely //! to the registry request's `Authorization` header. use chrono::{DateTime, Utc}; -use frameshift_catalog::{AccountRecord, PublisherMembershipRecord, PublisherProfileRecord}; +use frameshift_catalog::{ + AccountRecord, AccountStatus, PlatformRole, PlatformRoleRecord, PublisherMembershipRecord, + PublisherProfileRecord, +}; use secrecy::{ExposeSecret as _, SecretString}; use serde::{Deserialize, Serialize}; +use uuid::Uuid; use zeroize::Zeroizing; use crate::error::ClientError; @@ -123,6 +127,20 @@ struct LocalLogoutResponse { logged_out: bool, } +/// Caller-controlled field for one administrator platform-role grant. +#[derive(Serialize)] +struct AssignPlatformRoleRequest { + /// Global authority being granted to the target account. + role: PlatformRole, +} + +/// Caller-controlled field for one administrator account status transition. +#[derive(Serialize)] +struct SetAccountStatusRequest { + /// Status the target account must hold after the transition. + status: AccountStatus, +} + /// Authenticated account profile and its publisher memberships. #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] pub struct AccountView { @@ -339,6 +357,100 @@ pub fn get_account( } } +/// Grant one global platform role under authenticated administrator authority. +/// +/// # Errors +/// +/// Returns a registry URL, transport, status, size, JSON serialization, or JSON +/// response error without including the bearer token in the diagnostic. +pub fn assign_account_platform_role( + server_url: &str, + access_token: &SecretString, + account_id: Uuid, + role: PlatformRole, +) -> Result { + let url = administrator_account_url(server_url, account_id, &["platform-roles"])?; + send_account_json( + crate::registry::http_agent().post(url.as_str()), + &url, + access_token, + &AssignPlatformRoleRequest { role }, + ) +} + +/// Revoke one global platform role under authenticated administrator authority. +/// +/// # Errors +/// +/// Returns a registry URL, transport, status, size, or JSON response error +/// without including the bearer token in the diagnostic. +pub fn revoke_account_platform_role( + server_url: &str, + access_token: &SecretString, + account_id: Uuid, + role: PlatformRole, +) -> Result { + let role = match role { + PlatformRole::Moderator => "moderator", + PlatformRole::Administrator => "administrator", + }; + let url = administrator_account_url(server_url, account_id, &["platform-roles", role])?; + let request = crate::publisher::with_bearer( + crate::registry::http_agent().delete(url.as_str()), + access_token, + ); + crate::publisher::send_and_decode(request.call(), url.as_str()) +} + +/// Set one account lifecycle status under authenticated administrator authority. +/// +/// # Errors +/// +/// Returns a registry URL, transport, status, size, JSON serialization, or JSON +/// response error without including the bearer token in the diagnostic. +pub fn set_account_status( + server_url: &str, + access_token: &SecretString, + account_id: Uuid, + status: AccountStatus, +) -> Result { + let url = administrator_account_url(server_url, account_id, &["status"])?; + send_account_json( + crate::registry::http_agent().request("PATCH", url.as_str()), + &url, + access_token, + &SetAccountStatusRequest { status }, + ) +} + +/// Build one administrator account endpoint while preserving a registry base path. +fn administrator_account_url( + server_url: &str, + account_id: Uuid, + suffix: &[&str], +) -> Result { + let account = account_id.to_string(); + let mut segments = vec!["v1", "admin", "accounts", account.as_str()]; + segments.extend_from_slice(suffix); + crate::publisher::registry_endpoint_url(server_url, &segments) +} + +/// Send one bearer-authenticated administrator account JSON mutation. +fn send_account_json( + request: ureq::Request, + url: &url::Url, + access_token: &SecretString, + body: &T, +) -> Result { + let bytes = + serde_json::to_vec(body).map_err(|error| ClientError::JsonSerialize(error.to_string()))?; + let request = crate::publisher::with_bearer( + request.set("Content-Type", "application/json"), + access_token, + ); + crate::publisher::send_and_decode(request.send_bytes(&bytes), url.as_str()) +} + #[cfg(test)] /// Account HTTP client regression tests. mod tests { @@ -498,4 +610,59 @@ mod tests { assert!(request.contains("\r\nAuthorization: Bearer native-session-token\r\n")); assert_eq!(request.matches("native-session-token").count(), 1); } + + /// Administrator account controls preserve exact paths, methods, bearer authority, and bodies. + #[test] + fn sends_administrator_account_controls() { + let role_body = r#"{"account_id":"00000000-0000-0000-0000-000000000001","role":"administrator","state":"active","assigned_by_account_id":"00000000-0000-0000-0000-000000000002","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}"#; + let (server, handle) = serve_json_response(role_body); + let server = format!("{server}/registry"); + let token = SecretString::new("administrator-token".to_string()); + let role = assign_account_platform_role( + &server, + &token, + uuid::Uuid::from_u128(1), + frameshift_catalog::PlatformRole::Administrator, + ) + .expect("role grant response"); + assert_eq!(role.role, frameshift_catalog::PlatformRole::Administrator); + let request = handle.join().expect("test server thread"); + assert!(request.starts_with( + "POST /registry/v1/admin/accounts/00000000-0000-0000-0000-000000000001/platform-roles HTTP/1.1\r\n" + )); + assert!(request.contains("\r\nAuthorization: Bearer administrator-token\r\n")); + assert!(request.contains("\"role\":\"administrator\"")); + + let (server, handle) = serve_json_response(role_body); + let server = format!("{server}/registry"); + revoke_account_platform_role( + &server, + &token, + uuid::Uuid::from_u128(1), + frameshift_catalog::PlatformRole::Moderator, + ) + .expect("role revocation response"); + let request = handle.join().expect("test server thread"); + assert!(request.starts_with( + "DELETE /registry/v1/admin/accounts/00000000-0000-0000-0000-000000000001/platform-roles/moderator HTTP/1.1\r\n" + )); + assert!(request.contains("\r\nAuthorization: Bearer administrator-token\r\n")); + + let (server, handle) = serve_json_response(account_json()); + let server = format!("{server}/registry"); + let account = set_account_status( + &server, + &token, + uuid::Uuid::from_u128(1), + frameshift_catalog::AccountStatus::Suspended, + ) + .expect("account status response"); + assert_eq!(account.id, uuid::Uuid::from_u128(1)); + let request = handle.join().expect("test server thread"); + assert!(request.starts_with( + "PATCH /registry/v1/admin/accounts/00000000-0000-0000-0000-000000000001/status HTTP/1.1\r\n" + )); + assert!(request.contains("\r\nAuthorization: Bearer administrator-token\r\n")); + assert!(request.contains("\"status\":\"suspended\"")); + } } diff --git a/docs/wiki/CLI-Reference.md b/docs/wiki/CLI-Reference.md index 6fcddde..e7985a4 100644 --- a/docs/wiki/CLI-Reference.md +++ b/docs/wiki/CLI-Reference.md @@ -57,6 +57,19 @@ session. Registration and first-party login collect secrets only through hidden interactive prompts. `login --first-party` selects password login when the registry also advertises OIDC. +Active administrators can grant or revoke global platform roles and transition +account lifecycle states. These commands resolve bearer authority for the exact +registry and do not accept a token argument. + +```bash +frameshift account grant-role --server --account-id --role +frameshift account revoke-role --server --account-id --role +frameshift account set-status --server --account-id --status +``` + +The registry prevents revoking, suspending, or disabling the last active +administrator. + ### `frameshift install [OPTIONS] ` Install a persona by `name@version`. Use `--from-path ` to install from a diff --git a/docs/wiki/Operations-and-Observability.md b/docs/wiki/Operations-and-Observability.md index d1c196a..0f13f01 100644 --- a/docs/wiki/Operations-and-Observability.md +++ b/docs/wiki/Operations-and-Observability.md @@ -227,6 +227,15 @@ reads `0`; that is the intended fail-closed behavior, not an outage. | `DELETE /v1/admin/accounts/{account_id}/platform-roles/{role}` | Revoke a role, retaining it as auditable history | | `PATCH /v1/admin/accounts/{account_id}/status` | Set `active`, `suspended`, or `disabled` | +The account-control routes are available through the CLI with registry-bound +bearer authority and closed role and status values: + +```bash +frameshift account grant-role --server --account-id --role +frameshift account revoke-role --server --account-id --role +frameshift account set-status --server --account-id --status +``` + All three require an active administrator and return `403` with a fixed body to anyone else, including for a target account that does not exist, so the routes cannot be used to test whether an account is present.