use super::action_catalog::{LifecycleState, ACTIONS};
use super::enforcement_mode::legacy_rbac_decision;
use super::policies::{self as iam_policies, EvalContext, ResourceRef};
use super::store::AuthStore;
use super::{Role, UserId};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MigratePolicyDelta {
pub principal: UserId,
pub role: Role,
pub action: String,
pub resource_kind: String,
pub resource_name: String,
}
pub fn simulate_migration_delta(
store: &AuthStore,
resources: &[ResourceRef],
now_ms: u128,
) -> Vec<MigratePolicyDelta> {
let mut deltas: Vec<MigratePolicyDelta> = Vec::new();
let mut users = store.list_users();
users.sort_by(|a, b| {
a.tenant_id
.cmp(&b.tenant_id)
.then_with(|| a.username.cmp(&b.username))
});
for user in users {
let uid = UserId::from_parts(user.tenant_id.as_deref(), &user.username);
let role = user.role;
let principal_is_admin_role = role == Role::Admin;
let principal_is_system_owned = store.principal_is_system_owned(&uid);
let principal_is_platform_scoped = uid.tenant.is_none();
let ctx = EvalContext {
principal_tenant: uid.tenant.clone(),
current_tenant: uid.tenant.clone(),
peer_ip: None,
mfa_present: false,
now_ms,
principal_is_admin_role,
principal_is_system_owned,
principal_is_platform_scoped,
};
let pols = store.effective_policies(&uid);
let refs: Vec<&iam_policies::Policy> = pols.iter().map(|p| p.as_ref()).collect();
for entry in ACTIONS.iter() {
if matches!(entry.lifecycle_state, LifecycleState::Removed) {
continue;
}
let action = entry.name;
if !legacy_rbac_decision(role, action) {
continue;
}
for resource in resources {
let decision = iam_policies::evaluate(&refs, action, resource, &ctx);
let lost = matches!(decision, iam_policies::Decision::DefaultDeny);
if lost {
deltas.push(MigratePolicyDelta {
principal: uid.clone(),
role,
action: action.to_string(),
resource_kind: resource.kind.to_string(),
resource_name: resource.name.to_string(),
});
}
}
}
}
deltas
}
pub fn principal_label(uid: &UserId) -> String {
match &uid.tenant {
Some(t) => format!("{t}.{}", uid.username),
None => uid.username.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::policies::Policy;
use crate::auth::store::PrincipalRef;
use crate::auth::AuthConfig;
fn store_with_user(role: Role) -> (AuthStore, UserId) {
let store = AuthStore::new(AuthConfig::default());
store.create_user("alice", "p", role).unwrap();
(store, UserId::platform("alice"))
}
fn resources() -> Vec<ResourceRef> {
vec![ResourceRef::new("table", "orders")]
}
#[test]
fn write_role_without_policy_loses_writes() {
let (store, uid) = store_with_user(Role::Write);
let deltas = simulate_migration_delta(&store, &resources(), 0);
assert!(!deltas.is_empty(), "expected losses, got none");
assert!(
deltas.iter().all(|d| d.principal == uid),
"only alice should appear: {deltas:#?}"
);
assert!(
deltas.iter().any(|d| d.action == "select"),
"select should be among losses: {deltas:#?}"
);
}
#[test]
fn admin_role_with_bootstrap_allow_all_has_empty_delta() {
let (store, uid) = store_with_user(Role::Admin);
let policy = Policy::from_json_str(
r#"{"id":"p-bootstrap","version":1,
"statements":[{"effect":"allow","actions":["*"],"resources":["*"]}]}"#,
)
.unwrap();
store.put_policy(policy).unwrap();
store
.attach_policy(PrincipalRef::User(uid.clone()), "p-bootstrap")
.unwrap();
let deltas = simulate_migration_delta(&store, &resources(), 0);
assert!(
deltas.is_empty(),
"admin with allow-all should have empty delta, got {deltas:#?}"
);
}
#[test]
fn admin_role_without_any_policy_shows_up() {
let (store, _uid) = store_with_user(Role::Admin);
let deltas = simulate_migration_delta(&store, &resources(), 0);
assert!(!deltas.is_empty());
assert!(deltas.iter().all(|d| d.role == Role::Admin));
}
#[test]
fn read_role_with_select_allow_loses_nothing_on_select() {
let (store, uid) = store_with_user(Role::Read);
let policy = Policy::from_json_str(
r#"{"id":"p-select-orders","version":1,
"statements":[{"effect":"allow","actions":["select"],
"resources":["table:orders"]}]}"#,
)
.unwrap();
store.put_policy(policy).unwrap();
store
.attach_policy(PrincipalRef::User(uid.clone()), "p-select-orders")
.unwrap();
let deltas = simulate_migration_delta(&store, &resources(), 0);
assert!(
deltas.iter().all(|d| d.action != "select"),
"select on table:orders is covered: {deltas:#?}"
);
}
#[test]
fn read_role_actions_outside_role_floor_never_appear() {
let (store, _uid) = store_with_user(Role::Read);
let deltas = simulate_migration_delta(&store, &resources(), 0);
for d in &deltas {
assert!(
legacy_rbac_decision(Role::Read, &d.action),
"Read role would never have access to `{}` under legacy_rbac, \
so it should not appear in the delta: {d:?}",
d.action
);
assert!(
!["insert", "update", "delete", "write", "truncate"].contains(&d.action.as_str()),
"write-tier action {} leaked into Read principal delta",
d.action
);
}
}
}