1use super::action_catalog::{LifecycleState, ACTIONS};
20use super::enforcement_mode::legacy_rbac_decision;
21use super::policies::{self as iam_policies, EvalContext, ResourceRef};
22use super::store::AuthStore;
23use super::{Role, UserId};
24
25#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct MigratePolicyDelta {
29 pub principal: UserId,
30 pub role: Role,
31 pub action: String,
32 pub resource_kind: String,
33 pub resource_name: String,
34}
35
36pub fn simulate_migration_delta(
46 store: &AuthStore,
47 resources: &[ResourceRef],
48 now_ms: u128,
49) -> Vec<MigratePolicyDelta> {
50 let mut deltas: Vec<MigratePolicyDelta> = Vec::new();
51
52 let mut users = store.list_users();
54 users.sort_by(|a, b| {
55 a.tenant_id
56 .cmp(&b.tenant_id)
57 .then_with(|| a.username.cmp(&b.username))
58 });
59
60 for user in users {
61 let uid = UserId::from_parts(user.tenant_id.as_deref(), &user.username);
62 let role = user.role;
63 let principal_is_admin_role = role == Role::Admin;
64 let principal_is_platform_scoped = uid.tenant.is_none();
65 let ctx = EvalContext {
66 principal_tenant: uid.tenant.clone(),
67 current_tenant: uid.tenant.clone(),
68 peer_ip: None,
69 mfa_present: false,
70 now_ms,
71 principal_is_admin_role,
72 principal_is_platform_scoped,
73 };
74 let pols = store.effective_policies(&uid);
75 let refs: Vec<&iam_policies::Policy> = pols.iter().map(|p| p.as_ref()).collect();
76
77 for entry in ACTIONS.iter() {
78 if matches!(entry.lifecycle_state, LifecycleState::Removed) {
79 continue;
80 }
81 let action = entry.name;
82 if !legacy_rbac_decision(role, action) {
86 continue;
87 }
88 for resource in resources {
89 let decision = iam_policies::evaluate(&refs, action, resource, &ctx);
90 let lost = matches!(decision, iam_policies::Decision::DefaultDeny);
91 if lost {
92 deltas.push(MigratePolicyDelta {
93 principal: uid.clone(),
94 role,
95 action: action.to_string(),
96 resource_kind: resource.kind.to_string(),
97 resource_name: resource.name.to_string(),
98 });
99 }
100 }
101 }
102 }
103
104 deltas
105}
106
107pub fn principal_label(uid: &UserId) -> String {
111 match &uid.tenant {
112 Some(t) => format!("{t}.{}", uid.username),
113 None => uid.username.clone(),
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 use crate::auth::policies::Policy;
121 use crate::auth::store::PrincipalRef;
122 use crate::auth::AuthConfig;
123
124 fn store_with_user(role: Role) -> (AuthStore, UserId) {
125 let store = AuthStore::new(AuthConfig::default());
126 store.create_user("alice", "p", role).unwrap();
127 (store, UserId::platform("alice"))
128 }
129
130 fn resources() -> Vec<ResourceRef> {
131 vec![ResourceRef::new("table", "orders")]
132 }
133
134 #[test]
135 fn write_role_without_policy_loses_writes() {
136 let (store, uid) = store_with_user(Role::Write);
137 let deltas = simulate_migration_delta(&store, &resources(), 0);
138 assert!(!deltas.is_empty(), "expected losses, got none");
142 assert!(
143 deltas.iter().all(|d| d.principal == uid),
144 "only alice should appear: {deltas:#?}"
145 );
146 assert!(
147 deltas.iter().any(|d| d.action == "select"),
148 "select should be among losses: {deltas:#?}"
149 );
150 }
151
152 #[test]
153 fn admin_role_with_bootstrap_allow_all_has_empty_delta() {
154 let (store, uid) = store_with_user(Role::Admin);
155 let policy = Policy::from_json_str(
156 r#"{"id":"p-bootstrap","version":1,
157 "statements":[{"effect":"allow","actions":["*"],"resources":["*"]}]}"#,
158 )
159 .unwrap();
160 store.put_policy(policy).unwrap();
161 store
162 .attach_policy(PrincipalRef::User(uid.clone()), "p-bootstrap")
163 .unwrap();
164 let deltas = simulate_migration_delta(&store, &resources(), 0);
165 assert!(
166 deltas.is_empty(),
167 "admin with allow-all should have empty delta, got {deltas:#?}"
168 );
169 }
170
171 #[test]
172 fn admin_role_without_any_policy_shows_up() {
173 let (store, _uid) = store_with_user(Role::Admin);
178 let deltas = simulate_migration_delta(&store, &resources(), 0);
179 assert!(!deltas.is_empty());
180 assert!(deltas.iter().all(|d| d.role == Role::Admin));
181 }
182
183 #[test]
184 fn read_role_with_select_allow_loses_nothing_on_select() {
185 let (store, uid) = store_with_user(Role::Read);
186 let policy = Policy::from_json_str(
187 r#"{"id":"p-select-orders","version":1,
188 "statements":[{"effect":"allow","actions":["select"],
189 "resources":["table:orders"]}]}"#,
190 )
191 .unwrap();
192 store.put_policy(policy).unwrap();
193 store
194 .attach_policy(PrincipalRef::User(uid.clone()), "p-select-orders")
195 .unwrap();
196 let deltas = simulate_migration_delta(&store, &resources(), 0);
197 assert!(
198 deltas.iter().all(|d| d.action != "select"),
199 "select on table:orders is covered: {deltas:#?}"
200 );
201 }
202
203 #[test]
204 fn read_role_actions_outside_role_floor_never_appear() {
205 let (store, _uid) = store_with_user(Role::Read);
212 let deltas = simulate_migration_delta(&store, &resources(), 0);
213 for d in &deltas {
214 assert!(
215 legacy_rbac_decision(Role::Read, &d.action),
216 "Read role would never have access to `{}` under legacy_rbac, \
217 so it should not appear in the delta: {d:?}",
218 d.action
219 );
220 assert!(
221 !["insert", "update", "delete", "write", "truncate"].contains(&d.action.as_str()),
222 "write-tier action {} leaked into Read principal delta",
223 d.action
224 );
225 }
226 }
227}