1use af_context::{SubjectId, WorkflowResourceId, WorkflowSourceId};
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::collections::BTreeSet;
6
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
9#[serde(deny_unknown_fields)]
10pub struct WorkflowSourceGrant {
11 pub source: WorkflowSourceId,
13 pub event_type: String,
15 pub predicate: Value,
17}
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20#[serde(deny_unknown_fields)]
21pub struct WorkflowAdmissionPolicy {
22 pub enabled: bool,
24 pub max_active_instances: u32,
27 pub sources: Vec<WorkflowSourceGrant>,
29 pub resources: BTreeSet<WorkflowResourceId>,
31 pub providers: BTreeSet<String>,
33 pub modes: Vec<crate::ExecutionMode>,
35}
36impl WorkflowAdmissionPolicy {
37 pub fn validate(&self) -> Result<(), String> {
39 if self.sources.len() > 64
41 || self.resources.len() > 256
42 || self.providers.len() > 256
43 || self.modes.len() > 4
44 {
45 return Err("workflow admission exceeds grant collection bounds".into());
46 }
47 if self
48 .sources
49 .iter()
50 .any(|grant| grant.event_type.trim().is_empty() || !grant.predicate.is_object())
51 || self
52 .providers
53 .iter()
54 .any(|provider| provider.trim().is_empty())
55 {
56 return Err(
57 "workflow admission requires exact providers and source/object restrictions".into(),
58 );
59 }
60 if serde_json::to_vec(self)
61 .map_err(|error| error.to_string())?
62 .len()
63 > 65_536
64 {
65 return Err("workflow admission exceeds 64 KiB".into());
66 }
67 Ok(())
68 }
69 pub fn authorize_profile(
71 &self,
72 context: &af_context::RequestContext,
73 profile: &crate::ExecutionProfileRevision,
74 ) -> Result<(), String> {
75 if !self.enabled
76 || !self.modes.contains(&profile.mode)
77 || [
78 &profile.trigger_provider,
79 &profile.data_provider,
80 &profile.action_provider,
81 ]
82 .iter()
83 .any(|provider| !self.providers.contains(*provider))
84 || profile.connection_bindings.values().any(|resource| {
85 !self
86 .resources
87 .iter()
88 .any(|allowed| allowed.as_str() == resource)
89 })
90 {
91 return Err("workflow profile exceeds current mode/provider/resource admission".into());
92 }
93 if let Some(permissions) = profile.policy_bundle.get("permissions") {
94 let permissions = permissions
95 .as_array()
96 .ok_or("workflow profile permissions must be an array")?;
97 if permissions.iter().any(|permission| {
98 permission.as_str().is_none_or(|permission| {
99 !context.roles.contains(permission)
100 && !context.entitlements.contains(permission)
101 })
102 }) {
103 return Err("workflow profile exceeds current subject permissions".into());
104 }
105 }
106 Ok(())
107 }
108}
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111pub struct WorkflowAdmissionRecord {
112 pub subject_id: SubjectId,
114 pub version: u64,
116 pub policy: WorkflowAdmissionPolicy,
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123 #[test]
124 fn deployment_grants_bound_config_and_never_add_subject_permissions() {
125 let policy = WorkflowAdmissionPolicy {
126 enabled: true,
127 max_active_instances: 1,
128 sources: vec![WorkflowSourceGrant {
129 source: "source".parse().unwrap(),
130 event_type: "event".into(),
131 predicate: serde_json::json!({"owner":"self"}),
132 }],
133 resources: ["resource".parse().unwrap()].into(),
134 providers: ["provider".into()].into(),
135 modes: vec![crate::ExecutionMode::Paper],
136 };
137 let context = af_context::RequestContext {
138 tenant_id: "tenant".parse().unwrap(),
139 subject_id: "owner".parse().unwrap(),
140 request_id: "request".parse().unwrap(),
141 locale: "en".into(),
142 roles: ["allowed".into()].into(),
143 entitlements: Default::default(),
144 };
145 let profile:crate::ExecutionProfileRevision=serde_json::from_value(serde_json::json!({"id":"profile","revision":1,"content_digest":"digest","mode":"paper","durability_grade":"standard","trigger_provider":"provider","data_provider":"provider","action_provider":"provider","clock_model":"database","connection_bindings":{"primary":"resource"},"policy_bundle":{"permissions":["allowed"]}})).unwrap();
146 assert!(policy.validate().is_ok());
147 assert!(policy.authorize_profile(&context, &profile).is_ok());
148 let mut changed = policy.clone();
149 changed.enabled = false;
150 assert!(changed.authorize_profile(&context, &profile).is_err());
151 for field in [
152 "mode",
153 "action_provider",
154 "connection_bindings",
155 "policy_bundle",
156 ] {
157 let mut changed = serde_json::to_value(&profile).unwrap();
158 changed[field] = match field {
159 "mode" => "live".into(),
160 "action_provider" => "other".into(),
161 "connection_bindings" => serde_json::json!({"primary":"other"}),
162 _ => serde_json::json!({"permissions":["admin"]}),
163 };
164 assert!(policy
165 .authorize_profile(&context, &serde_json::from_value(changed).unwrap())
166 .is_err());
167 }
168 for permissions in [serde_json::json!("allowed"), serde_json::json!([null])] {
169 let mut changed = profile.clone();
170 changed.policy_bundle = serde_json::json!({"permissions":permissions});
171 assert!(policy.authorize_profile(&context, &changed).is_err());
172 }
173 let mut changed = profile;
174 changed.policy_bundle = Value::Null;
175 assert!(policy.authorize_profile(&context, &changed).is_ok());
176 for variant in 0..8 {
177 let mut changed = policy.clone();
178 match variant {
179 0 => changed.sources = vec![policy.sources[0].clone(); 65],
180 1 => changed.resources = (0..257).map(|i| i.to_string().parse().unwrap()).collect(),
181 2 => changed.providers = (0..257).map(|i| i.to_string()).collect(),
182 3 => changed.modes = vec![crate::ExecutionMode::Paper; 5],
183 4 => changed.sources[0].event_type = " ".into(),
184 5 => changed.sources[0].predicate = Value::Null,
185 6 => {
186 changed.providers.insert(" ".into());
187 }
188 _ => changed.sources[0].predicate = serde_json::json!({"large":"x".repeat(65536)}),
189 }
190 assert!(changed.validate().is_err(), "case {variant}");
191 }
192 }
193}