Skip to main content

syncular_runtime/core/
auth_lease_selection.rs

1use crate::app_schema::AppTableMetadata;
2use crate::error::{Result, SyncularError};
3use crate::protocol::{
4    AuthLeasePayload, AuthLeaseProvenance, SyncOperation, AUTH_LEASE_CODE_EXPIRED,
5    AUTH_LEASE_CODE_MISSING, AUTH_LEASE_CODE_SCOPE_MISMATCH,
6};
7use crate::store::AuthLeaseRecord;
8use serde_json::{Map, Value};
9
10#[derive(Debug, Clone)]
11pub struct ActiveAuthLeasePolicy<'a> {
12    pub actor_id: Option<&'a str>,
13    pub now_ms: i64,
14}
15
16#[derive(Debug, Clone)]
17pub struct MutationOperationScope {
18    pub table: String,
19    pub op: String,
20    pub scopes: Map<String, Value>,
21    pub requires_scope_values: bool,
22    pub missing_required_scope_values: bool,
23}
24
25pub fn app_table_operation_scope(
26    metadata: &AppTableMetadata,
27    operation: &SyncOperation,
28    row: Option<&Value>,
29    row_exists_or_will_be_written: bool,
30) -> MutationOperationScope {
31    let mut scopes = Map::new();
32    if let Some(Value::Object(object)) = row {
33        for scope in metadata.scopes {
34            if let Some(value) = object.get(scope.column).and_then(scope_value_string) {
35                scopes.insert(scope.name.to_string(), Value::String(value));
36            }
37        }
38    }
39    let missing_required_scope_values = row_exists_or_will_be_written
40        && metadata.scopes.iter().any(|scope| {
41            scope.required
42                && !matches!(scopes.get(scope.name), Some(Value::String(value)) if !value.is_empty())
43        });
44
45    MutationOperationScope {
46        table: operation.table.clone(),
47        op: operation.op.clone(),
48        scopes,
49        requires_scope_values: row_exists_or_will_be_written
50            && metadata.scopes.iter().any(|scope| scope.required),
51        missing_required_scope_values,
52    }
53}
54
55pub fn system_table_operation_scope(operation: &SyncOperation) -> MutationOperationScope {
56    let scopes = operation
57        .payload
58        .as_ref()
59        .and_then(|payload| payload.get("scopes"))
60        .and_then(Value::as_object)
61        .map(|object| {
62            object
63                .iter()
64                .filter_map(|(key, value)| {
65                    scope_value_string(value).map(|value| (key.clone(), Value::String(value)))
66                })
67                .collect()
68        })
69        .unwrap_or_default();
70
71    MutationOperationScope {
72        table: operation.table.clone(),
73        op: operation.op.clone(),
74        scopes,
75        requires_scope_values: false,
76        missing_required_scope_values: false,
77    }
78}
79
80pub fn select_active_auth_lease_for_operations(
81    policy: ActiveAuthLeasePolicy<'_>,
82    candidate_leases: Vec<AuthLeaseRecord>,
83    current_schema_version: i32,
84    operations: &[MutationOperationScope],
85) -> Result<AuthLeaseProvenance> {
86    for operation in operations {
87        if operation.missing_required_scope_values {
88            return Err(SyncularError::protocol_message(format!(
89                "{}: mutation for table {} is missing required lease scope values",
90                AUTH_LEASE_CODE_SCOPE_MISMATCH, operation.table
91            )));
92        }
93    }
94
95    let mut saw_expired_covering_lease = false;
96    for lease in candidate_leases {
97        if lease.status != "active" {
98            continue;
99        }
100        if lease.schema_version != current_schema_version {
101            continue;
102        }
103        let Ok(payload) = serde_json::from_str::<AuthLeasePayload>(&lease.payload_json) else {
104            continue;
105        };
106        if payload.schema_version != current_schema_version {
107            continue;
108        }
109        if let Some(actor_id) = policy.actor_id {
110            if payload.actor_id != actor_id {
111                continue;
112            }
113        }
114        if auth_lease_payload_covers_operations(&payload, operations) {
115            if lease.not_before_ms > policy.now_ms || payload.not_before_ms > policy.now_ms {
116                continue;
117            }
118            if lease.expires_at_ms <= policy.now_ms || payload.expires_at_ms <= policy.now_ms {
119                saw_expired_covering_lease = true;
120                continue;
121            }
122            return Ok(AuthLeaseProvenance {
123                lease_id: lease.lease_id,
124                lease_expires_at_ms: lease.expires_at_ms,
125                lease_status_at_enqueue: lease.status,
126                lease_scope_summary_json: serde_json::to_string(&payload.scopes).ok(),
127                lease_token: Some(lease.token),
128            });
129        }
130    }
131
132    if saw_expired_covering_lease {
133        return Err(SyncularError::protocol_message(format!(
134            "{}: matching auth lease is expired",
135            AUTH_LEASE_CODE_EXPIRED
136        )));
137    }
138
139    Err(SyncularError::protocol_message(format!(
140        "{}: no active auth lease covers generated mutation batch",
141        AUTH_LEASE_CODE_MISSING
142    )))
143}
144
145fn auth_lease_payload_covers_operations(
146    payload: &AuthLeasePayload,
147    operations: &[MutationOperationScope],
148) -> bool {
149    operations.iter().all(|operation| {
150        let Some(scope) = payload.scopes.iter().find(|scope| {
151            scope.table == operation.table
152                && scope
153                    .operations
154                    .iter()
155                    .any(|allowed_op| allowed_op == &operation.op)
156        }) else {
157            return false;
158        };
159
160        if operation.requires_scope_values && operation.scopes.is_empty() {
161            return false;
162        }
163
164        operation.scopes.iter().all(|(name, value)| {
165            scope
166                .values
167                .get(name)
168                .is_some_and(|lease_value| lease_scope_value_covers(lease_value, value))
169        })
170    })
171}
172
173fn lease_scope_value_covers(lease_value: &Value, requested_value: &Value) -> bool {
174    let Some(requested) = scope_value_string(requested_value) else {
175        return false;
176    };
177    match lease_value {
178        Value::String(value) => value == "*" || value == &requested,
179        Value::Array(values) => values.iter().any(|value| {
180            value
181                .as_str()
182                .is_some_and(|value| value == "*" || value == requested)
183        }),
184        other => scope_value_string(other).is_some_and(|value| value == "*" || value == requested),
185    }
186}
187
188fn scope_value_string(value: &Value) -> Option<String> {
189    match value {
190        Value::Null => None,
191        Value::String(value) => {
192            if value.is_empty() {
193                None
194            } else {
195                Some(value.clone())
196            }
197        }
198        Value::Number(value) => Some(value.to_string()),
199        Value::Bool(value) => Some(value.to_string()),
200        Value::Array(_) | Value::Object(_) => None,
201    }
202}