Skip to content

Commit 0a2e18f

Browse files
easelclaude
andcommitted
feat(auth,server,cli): per-principal RBAC user-role registry (US-048)
Adds an explicit user-role registry backed by the control-plane SQLite database. Tailscale provides identity; Axon now owns authorisation. Role resolution priority: 1. Axon user-role registry (login → role, overrides everything) 2. Tailscale ACL tag mapping (tag:axon-admin/write/read) 3. --tailscale-default-role fallback Changes: - user_roles.rs: new UserRoleStore (Arc<RwLock<HashMap>>) with write-through SQLite persistence and full unit tests - control_plane.rs: migrate_user_roles() in schema migration; wrapper methods list/set/remove_user_role() - auth.rs: AuthContext gains user_roles field; resolve_peer checks registry before tag-based fallback; with_user_roles() builder; two new tests for registry override behaviour - control_plane_routes.rs: ControlPlaneState gains UserRoleStore; GET /control/users, PUT /control/users/{login}, DELETE /control/users/{login} endpoints; 5 new HTTP tests - serve.rs: loads user roles from DB at startup, shares store with both AuthContext and ControlPlaneState - axon-cli: `axon user grant/revoke/list` — HTTP client mode calls REST API; embedded mode opens control-plane SQLite directly - axon-config: control_plane_sqlite_path() helper - gateway.rs / service.rs: update actor assertions to user_login (identity_from_tailscale already returned login; tests were stale) Spec: FEAT-012-authorization.md updated with US-048 and V4 status row. Verification: cargo check --workspace, cargo test --workspace (254 lib tests pass; 1 pre-existing api_contract failure unrelated) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a23aa9b commit 0a2e18f

12 files changed

Lines changed: 920 additions & 21 deletions

File tree

crates/axon-cli/src/client.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,39 @@ impl HttpClient {
416416
Self::parse_response(resp)
417417
}
418418

419+
// ── User-role management ─────────────────────────────────────────────────
420+
421+
/// `GET /control/users` — list all explicit user-role assignments.
422+
pub fn list_users(&self) -> Result<Value> {
423+
let resp = self
424+
.client
425+
.get(format!("{}/control/users", self.base_url))
426+
.send()
427+
.context("failed to send list-users request")?;
428+
Self::parse_response(resp)
429+
}
430+
431+
/// `PUT /control/users/{login}` — assign a role to a principal.
432+
pub fn set_user_role(&self, login: &str, role: &str) -> Result<Value> {
433+
let resp = self
434+
.client
435+
.put(format!("{}/control/users/{login}", self.base_url))
436+
.json(&serde_json::json!({ "role": role }))
437+
.send()
438+
.context("failed to send set-user-role request")?;
439+
Self::parse_response(resp)
440+
}
441+
442+
/// `DELETE /control/users/{login}` — remove an explicit role assignment.
443+
pub fn remove_user_role(&self, login: &str) -> Result<Value> {
444+
let resp = self
445+
.client
446+
.delete(format!("{}/control/users/{login}", self.base_url))
447+
.send()
448+
.context("failed to send remove-user-role request")?;
449+
Self::parse_response(resp)
450+
}
451+
419452
// ── Internal helpers ─────────────────────────────────────────────────────
420453

421454
/// Parse a response: if 2xx, return the JSON body; otherwise return an error.

crates/axon-cli/src/main.rs

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,33 @@ enum Command {
145145
#[arg(long, short = 'd', default_value = "1")]
146146
depth: usize,
147147
},
148+
149+
/// Manage per-principal role assignments.
150+
#[cfg(feature = "serve")]
151+
#[command(subcommand)]
152+
User(UserCmd),
153+
}
154+
155+
#[cfg(feature = "serve")]
156+
#[derive(Subcommand)]
157+
enum UserCmd {
158+
/// Grant a role to a principal (creates or updates the assignment).
159+
Grant {
160+
/// The user's login name (e.g. `erik@example.com`).
161+
login: String,
162+
/// The role to assign: `admin`, `write`, or `read`.
163+
role: String,
164+
},
165+
/// Revoke the explicit role assignment for a principal.
166+
///
167+
/// After revocation, the principal falls back to tag-based or default role
168+
/// resolution on their next request.
169+
Revoke {
170+
/// The user's login name.
171+
login: String,
172+
},
173+
/// List all explicit user-role assignments.
174+
List,
148175
}
149176

150177
#[derive(Subcommand)]
@@ -645,6 +672,11 @@ pub fn run(cli: Cli) -> Result<()> {
645672
println!("{}", axon_config::paths::config_file().display());
646673
return Ok(());
647674
}
675+
#[cfg(feature = "serve")]
676+
Command::User(_) => {
677+
// User commands are handled in client mode (HTTP) or embedded mode
678+
// (direct SQLite) below; no early return here.
679+
}
648680
_ => {}
649681
}
650682

@@ -709,13 +741,97 @@ pub fn run(cli: Cli) -> Result<()> {
709741
&cli.output,
710742
&mut handler,
711743
),
744+
// User-role commands against the control-plane database directly.
745+
#[cfg(feature = "serve")]
746+
Command::User(cmd) => run_user_embedded(cmd, &cli.output),
712747
// Already handled above; unreachable
713748
#[cfg(feature = "serve")]
714749
Command::Serve(_) | Command::Mcp { .. } => unreachable!(),
715750
Command::Doctor | Command::Init { .. } | Command::Server(_) | Command::Config(ConfigCmd::Path) => unreachable!(),
716751
}
717752
}
718753

754+
/// Run `axon user` commands against the control-plane SQLite database directly
755+
/// (no server required).
756+
#[cfg(feature = "serve")]
757+
fn run_user_embedded(cmd: UserCmd, format: &OutputFormat) -> Result<()> {
758+
use axon_server::auth::Role;
759+
use axon_server::control_plane::ControlPlaneDb;
760+
761+
let cp_path = axon_config::paths::control_plane_sqlite_path()
762+
.to_string_lossy()
763+
.into_owned();
764+
let db = ControlPlaneDb::open(&cp_path)
765+
.with_context(|| format!("failed to open control-plane database: {cp_path}"))?;
766+
767+
match cmd {
768+
UserCmd::List => {
769+
let entries = db
770+
.list_user_roles()
771+
.map_err(|e| anyhow::anyhow!("failed to list user roles: {e}"))?;
772+
let users: Vec<serde_json::Value> = entries
773+
.iter()
774+
.map(|e| serde_json::json!({ "login": e.login, "role": e.role }))
775+
.collect();
776+
match format {
777+
OutputFormat::Json | OutputFormat::Yaml => {
778+
print_serialized(&serde_json::json!({ "users": users }), format);
779+
}
780+
OutputFormat::Table => {
781+
if entries.is_empty() {
782+
println!("No explicit user-role assignments.");
783+
} else {
784+
for e in &entries {
785+
let role_str = serde_json::to_string(&e.role).unwrap_or_default();
786+
println!("{:<40} {}", e.login, role_str.trim_matches('"'));
787+
}
788+
}
789+
}
790+
}
791+
Ok(())
792+
}
793+
UserCmd::Grant { login, role } => {
794+
let role: Role = match role.as_str() {
795+
"admin" => Role::Admin,
796+
"write" => Role::Write,
797+
"read" => Role::Read,
798+
other => anyhow::bail!("unknown role '{other}'; must be admin, write, or read"),
799+
};
800+
db.set_user_role(&login, &role)
801+
.map_err(|e| anyhow::anyhow!("failed to set role: {e}"))?;
802+
match format {
803+
OutputFormat::Json | OutputFormat::Yaml => {
804+
print_serialized(&serde_json::json!({ "login": login, "role": role }), format);
805+
}
806+
OutputFormat::Table => {
807+
let role_str = serde_json::to_string(&role).unwrap_or_default();
808+
println!("Granted {} to {login}", role_str.trim_matches('"'));
809+
}
810+
}
811+
Ok(())
812+
}
813+
UserCmd::Revoke { login } => {
814+
let removed = db
815+
.remove_user_role(&login)
816+
.map_err(|e| anyhow::anyhow!("failed to revoke role: {e}"))?;
817+
if removed {
818+
match format {
819+
OutputFormat::Json | OutputFormat::Yaml => {
820+
print_serialized(
821+
&serde_json::json!({ "login": login, "deleted": true }),
822+
format,
823+
);
824+
}
825+
OutputFormat::Table => println!("Revoked explicit role for {login}"),
826+
}
827+
} else {
828+
anyhow::bail!("no explicit role assigned to '{login}'");
829+
}
830+
Ok(())
831+
}
832+
}
833+
}
834+
719835
#[cfg(feature = "serve")]
720836
fn run_server_command(cli: Cli) -> Result<()> {
721837
let rt = tokio::runtime::Runtime::new().context("failed to create tokio runtime")?;
@@ -2264,6 +2380,21 @@ fn run_client_mode(cli: Cli, client: client::HttpClient) -> Result<()> {
22642380
Command::Bead(_) => {
22652381
anyhow::bail!("bead commands are not yet available in client mode");
22662382
}
2383+
#[cfg(feature = "serve")]
2384+
Command::User(cmd) => match cmd {
2385+
UserCmd::List => {
2386+
let resp = client.list_users()?;
2387+
print_serialized(&resp, &cli.output);
2388+
}
2389+
UserCmd::Grant { login, role } => {
2390+
let resp = client.set_user_role(&login, &role)?;
2391+
print_serialized(&resp, &cli.output);
2392+
}
2393+
UserCmd::Revoke { login } => {
2394+
let resp = client.remove_user_role(&login)?;
2395+
print_serialized(&resp, &cli.output);
2396+
}
2397+
},
22672398
// These are handled before mode detection; unreachable in client mode
22682399
Command::Serve(_) | Command::Mcp { .. } | Command::Doctor | Command::Init { .. }
22692400
| Command::Server(_) | Command::Config(ConfigCmd::Path) => unreachable!(),

crates/axon-config/src/paths.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ pub fn default_sqlite_path() -> PathBuf {
5050
data_dir().join("axon.db")
5151
}
5252

53+
/// Returns the default path for the control-plane SQLite database.
54+
///
55+
/// Equivalent to `data_dir()/axon-control-plane.db`.
56+
pub fn control_plane_sqlite_path() -> PathBuf {
57+
data_dir().join("axon-control-plane.db")
58+
}
59+
5360
/// Returns the directory for per-tenant data.
5461
///
5562
/// Equivalent to `data_dir()/tenants/`.

crates/axon-server/src/auth.rs

Lines changed: 115 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,8 @@ pub struct AuthContext {
377377
provider: Option<Arc<dyn TailscaleWhoisProvider>>,
378378
cache: Arc<RwLock<HashMap<IpAddr, CachedIdentity>>>,
379379
cache_ttl: Duration,
380+
/// Per-principal role registry. Overrides tag-based role resolution.
381+
user_roles: crate::user_roles::UserRoleStore,
380382
}
381383

382384
impl Default for AuthContext {
@@ -393,6 +395,7 @@ impl AuthContext {
393395
provider: None,
394396
cache: Arc::new(RwLock::new(HashMap::new())),
395397
cache_ttl: Duration::from_secs(60),
398+
user_roles: crate::user_roles::UserRoleStore::default(),
396399
}
397400
}
398401

@@ -403,6 +406,7 @@ impl AuthContext {
403406
provider: None,
404407
cache: Arc::new(RwLock::new(HashMap::new())),
405408
cache_ttl: Duration::from_secs(0),
409+
user_roles: crate::user_roles::UserRoleStore::default(),
406410
}
407411
}
408412

@@ -417,6 +421,22 @@ impl AuthContext {
417421
Self::with_provider(mode, provider, cache_ttl)
418422
}
419423

424+
/// Replace the user-role store with `store`.
425+
///
426+
/// Both the returned `AuthContext` and the original `store` handle share
427+
/// the same underlying `Arc`, so mutations through either are immediately
428+
/// visible.
429+
#[must_use]
430+
pub fn with_user_roles(mut self, store: crate::user_roles::UserRoleStore) -> Self {
431+
self.user_roles = store;
432+
self
433+
}
434+
435+
/// Return a reference to the shared user-role store.
436+
pub fn user_roles(&self) -> &crate::user_roles::UserRoleStore {
437+
&self.user_roles
438+
}
439+
420440
#[must_use]
421441
pub fn mode(&self) -> &AuthMode {
422442
&self.mode
@@ -453,7 +473,23 @@ impl AuthContext {
453473
"tailscale auth is enabled but no LocalAPI provider is configured".into(),
454474
)
455475
})?;
456-
let identity = identity_from_tailscale(&provider.whois(peer).await?, default_role);
476+
let whois = provider.whois(peer).await?;
477+
478+
// Priority: (1) Axon user-role registry by login, (2) ACL tags,
479+
// (3) --tailscale-default-role.
480+
let role = if whois.user_login.is_empty() {
481+
role_from_tags(&whois.tags, default_role)
482+
} else {
483+
self.user_roles
484+
.get(&whois.user_login)
485+
.unwrap_or_else(|| role_from_tags(&whois.tags, default_role))
486+
};
487+
let actor = if whois.user_login.is_empty() {
488+
whois.node_name.clone()
489+
} else {
490+
whois.user_login.clone()
491+
};
492+
let identity = Identity { actor, role };
457493
self.store_cached_identity(peer_ip, identity.clone()).await;
458494
Ok(identity)
459495
}
@@ -491,6 +527,7 @@ impl AuthContext {
491527
provider: Some(provider),
492528
cache: Arc::new(RwLock::new(HashMap::new())),
493529
cache_ttl,
530+
user_roles: crate::user_roles::UserRoleStore::default(),
494531
}
495532
}
496533
}
@@ -548,10 +585,10 @@ const fn role_priority(role: &Role) -> u8 {
548585
/// whose login name is empty, the node name is used as a fallback so that
549586
/// automated agents still produce a meaningful actor string.
550587
pub fn identity_from_tailscale(whois: &TailscaleWhoisResponse, default_role: &Role) -> Identity {
551-
let actor = if !whois.user_login.is_empty() {
552-
whois.user_login.clone()
553-
} else {
588+
let actor = if whois.user_login.is_empty() {
554589
whois.node_name.clone()
590+
} else {
591+
whois.user_login.clone()
555592
};
556593
Identity {
557594
actor,
@@ -977,4 +1014,78 @@ mod tests {
9771014
assert!(id.require_write().is_ok());
9781015
assert!(id.require_admin().is_ok());
9791016
}
1017+
1018+
// ── User-role registry tests (US-048) ─────────────────────────────────────
1019+
1020+
#[tokio::test]
1021+
async fn user_role_registry_overrides_tag_based_role() {
1022+
use crate::user_roles::{UserRoleEntry, UserRoleStore};
1023+
1024+
let address = SocketAddr::from(([100, 101, 102, 103], 443));
1025+
// Tailscale says the node has no axon tags → default role would be Read.
1026+
let provider = Arc::new(FakeWhoisProvider::with_result(
1027+
address,
1028+
Ok(TailscaleWhoisResponse {
1029+
node_name: "erikd-laptop".into(),
1030+
user_login: "erik@example.com".into(),
1031+
tags: vec![],
1032+
}),
1033+
));
1034+
let store = UserRoleStore::default();
1035+
store.load_from_entries(vec![UserRoleEntry {
1036+
login: "erik@example.com".into(),
1037+
role: Role::Write,
1038+
}]);
1039+
let context = AuthContext::with_provider(
1040+
AuthMode::Tailscale {
1041+
default_role: Role::Read,
1042+
},
1043+
provider,
1044+
Duration::from_secs(60),
1045+
)
1046+
.with_user_roles(store);
1047+
1048+
let identity = context
1049+
.resolve_peer(Some(address))
1050+
.await
1051+
.expect("should resolve");
1052+
assert_eq!(identity.actor, "erik@example.com");
1053+
// Registry grants Write, overriding the Read default.
1054+
assert_eq!(identity.role, Role::Write);
1055+
}
1056+
1057+
#[tokio::test]
1058+
async fn user_role_registry_overrides_acl_tag_role() {
1059+
use crate::user_roles::{UserRoleEntry, UserRoleStore};
1060+
1061+
let address = SocketAddr::from(([100, 101, 102, 104], 443));
1062+
// Tailscale tags say Admin, but registry says Read.
1063+
let provider = Arc::new(FakeWhoisProvider::with_result(
1064+
address,
1065+
Ok(TailscaleWhoisResponse {
1066+
node_name: "erikd-laptop".into(),
1067+
user_login: "restricted@example.com".into(),
1068+
tags: vec!["tag:axon-admin".into()],
1069+
}),
1070+
));
1071+
let store = UserRoleStore::default();
1072+
store.load_from_entries(vec![UserRoleEntry {
1073+
login: "restricted@example.com".into(),
1074+
role: Role::Read,
1075+
}]);
1076+
let context = AuthContext::with_provider(
1077+
AuthMode::Tailscale {
1078+
default_role: Role::Read,
1079+
},
1080+
provider,
1081+
Duration::from_secs(60),
1082+
)
1083+
.with_user_roles(store);
1084+
1085+
let identity = context
1086+
.resolve_peer(Some(address))
1087+
.await
1088+
.expect("should resolve");
1089+
assert_eq!(identity.role, Role::Read);
1090+
}
9801091
}

0 commit comments

Comments
 (0)