Skip to main content

reddb_server/auth/
migrate_policy_mode.rs

1//! Pre-flight delta simulator for `MIGRATE POLICY MODE` (#714).
2//!
3//! Computes, for every non-admin principal in the install, the set of
4//! `(action, resource)` pairs that the principal can access **today**
5//! under [`PolicyEnforcementMode::LegacyRbac`] but would lose access to
6//! under [`PolicyEnforcementMode::PolicyOnly`].
7//!
8//! The simulator is pure with respect to the [`AuthStore`]: it does not
9//! mutate any state. Callers (the SQL executor for `MIGRATE POLICY MODE
10//! TO 'policy_only' [DRY RUN]` and the matching HTTP endpoint) use the
11//! returned delta to either refuse the migration (non-empty delta, no
12//! DRY RUN) or return it as a result set (DRY RUN).
13//!
14//! The "admin holds bootstrap allow-all" carve-out from the spec is
15//! enforced naturally: a principal with a wildcard `Allow` on `*`/`*`
16//! receives [`iam_policies::Decision::Allow`] under either mode, so
17//! they never appear in the delta. We don't filter them out by name.
18
19use 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/// One row in the migration delta — a principal that would lose access
26/// to `(action, resource)` if the install switched to `policy_only`.
27#[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
36/// Run the pre-flight simulator.
37///
38/// `resources` is the list of `(kind, name)` resource references to
39/// probe. The caller decides what populates this — the SQL executor
40/// passes every existing collection ("table:<name>"); tests can pass a
41/// minimal set.
42///
43/// Returns deltas in stable order: by principal (UserId sort), then by
44/// action (catalog order), then by resource (input order).
45pub 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    // Sort users by (tenant, username) so the output order is stable.
53    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            // Per-action shortcut: if the legacy RBAC posture would
83            // already deny this action for the principal's role, there
84            // is no access to lose. Skip the whole resource sweep.
85            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
107/// Format a [`UserId`] as `tenant.username` (or just `username` for the
108/// platform scope). Used by both the SQL result set and the HTTP body
109/// so the two surfaces produce identical principal labels.
110pub 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        // Write role can do read+write actions under legacy_rbac. Each
139        // such action paired with the single probed resource should be
140        // a loss because the principal has no policies attached.
141        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        // An Admin role principal without the bootstrap allow-all
174        // policy can do anything under legacy_rbac (via the role
175        // fallback) but nothing under policy_only — every probed
176        // (action, resource) is a delta row.
177        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        // A Read principal cannot perform write/admin actions under
206        // legacy_rbac either — so even though policy_only would also
207        // deny them, no access is "lost" by the migration. Delta rows
208        // for a Read principal must only carry actions whose
209        // legacy_rbac floor is `Read` (e.g. `select`, `ai:*` reads);
210        // write- or admin-floored actions must be absent.
211        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}