syncular_runtime/core/
auth_lease_selection.rs1use 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}