1pub mod v1 {
4 tonic::include_proto!("keldra.v1");
5}
6
7pub mod typed_json;
8
9pub const FILE_DESCRIPTOR_SET: &[u8] = tonic::include_file_descriptor_set!("keldra_descriptor");
11
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14pub enum PredicateExpressionError {
15 EmptyConjunction,
16 EmptyDisjunction,
17}
18
19impl std::fmt::Display for PredicateExpressionError {
20 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21 match self {
22 Self::EmptyConjunction => {
23 formatter.write_str("a predicate conjunction requires at least one child")
24 }
25 Self::EmptyDisjunction => {
26 formatter.write_str("a predicate disjunction requires at least one child")
27 }
28 }
29 }
30}
31
32impl std::error::Error for PredicateExpressionError {}
33
34impl v1::IndexPredicateExpression {
35 pub fn leaf(predicate: v1::IndexPredicate) -> Self {
37 Self {
38 expression: Some(v1::index_predicate_expression::Expression::Predicate(
39 predicate,
40 )),
41 }
42 }
43
44 pub fn all(
46 expressions: impl IntoIterator<Item = Self>,
47 ) -> Result<Self, PredicateExpressionError> {
48 let expressions = expressions.into_iter().collect::<Vec<_>>();
49 if expressions.is_empty() {
50 return Err(PredicateExpressionError::EmptyConjunction);
51 }
52 Ok(Self {
53 expression: Some(v1::index_predicate_expression::Expression::Conjunction(
54 v1::IndexPredicateConjunction { expressions },
55 )),
56 })
57 }
58
59 pub fn any(
61 expressions: impl IntoIterator<Item = Self>,
62 ) -> Result<Self, PredicateExpressionError> {
63 let expressions = expressions.into_iter().collect::<Vec<_>>();
64 if expressions.is_empty() {
65 return Err(PredicateExpressionError::EmptyDisjunction);
66 }
67 Ok(Self {
68 expression: Some(v1::index_predicate_expression::Expression::Disjunction(
69 v1::IndexPredicateDisjunction { expressions },
70 )),
71 })
72 }
73
74 pub fn negated(self) -> Self {
76 Self {
77 expression: Some(v1::index_predicate_expression::Expression::Negation(
78 Box::new(v1::IndexPredicateNegation {
79 expression: Some(Box::new(self)),
80 }),
81 )),
82 }
83 }
84}
85
86#[cfg(test)]
87mod tests {
88 use prost::Message;
89 use prost_types::{DescriptorProto, FileDescriptorSet, ServiceDescriptorProto};
90
91 use super::v1::{
92 CloneObjectRequest, DeletedObject, Durability, LinkObjectRequest, NeverExisted,
93 ObjectAddress, ObjectHead, PresentObject, PutIfVersionOperation, UnlinkObjectRequest,
94 clone_object_request, object_head,
95 };
96
97 fn descriptors() -> FileDescriptorSet {
98 FileDescriptorSet::decode(super::FILE_DESCRIPTOR_SET).unwrap()
99 }
100
101 fn service<'a>(set: &'a FileDescriptorSet, name: &str) -> &'a ServiceDescriptorProto {
102 set.file
103 .iter()
104 .flat_map(|file| &file.service)
105 .find(|service| service.name.as_deref() == Some(name))
106 .unwrap_or_else(|| panic!("missing generated service descriptor {name}"))
107 }
108
109 fn message<'a>(set: &'a FileDescriptorSet, name: &str) -> &'a DescriptorProto {
110 set.file
111 .iter()
112 .flat_map(|file| &file.message_type)
113 .find(|message| message.name.as_deref() == Some(name))
114 .unwrap_or_else(|| panic!("missing generated message descriptor {name}"))
115 }
116
117 fn field_number(set: &FileDescriptorSet, message_name: &str, field_name: &str) -> i32 {
118 message(set, message_name)
119 .field
120 .iter()
121 .find(|field| field.name.as_deref() == Some(field_name))
122 .and_then(|field| field.number)
123 .unwrap_or_else(|| panic!("missing generated field {message_name}.{field_name}"))
124 }
125
126 #[test]
127 fn exact_path_states_are_distinct() {
128 let states = [
129 ObjectHead {
130 state: Some(object_head::State::Present(PresentObject {
131 version: 7,
132 content_hash: vec![7; 32],
133 content_length: 99,
134 content_type: String::new(),
135 })),
136 },
137 ObjectHead {
138 state: Some(object_head::State::Deleted(DeletedObject { version: 8 })),
139 },
140 ObjectHead {
141 state: Some(object_head::State::NeverExisted(NeverExisted {})),
142 },
143 ];
144
145 assert!(matches!(
146 &states[0].state,
147 Some(object_head::State::Present(_))
148 ));
149 assert!(matches!(
150 &states[1].state,
151 Some(object_head::State::Deleted(_))
152 ));
153 assert!(matches!(
154 &states[2].state,
155 Some(object_head::State::NeverExisted(_))
156 ));
157 }
158
159 #[test]
160 fn clone_object_wire_round_trip_preserves_both_identities_and_exact_cas() {
161 let request = CloneObjectRequest {
162 source: Some(ObjectAddress {
163 tenant: "tenant".into(),
164 bucket: "bucket".into(),
165 path: "source".into(),
166 }),
167 source_version: 17,
168 destination: Some(ObjectAddress {
169 tenant: "tenant".into(),
170 bucket: "bucket".into(),
171 path: "destination".into(),
172 }),
173 command_id: "clone-17".into(),
174 durability: Durability::Replicated as i32,
175 operation: Some(clone_object_request::Operation::PutIfVersion(
176 PutIfVersionOperation {
177 expected_version: 11,
178 },
179 )),
180 };
181
182 let decoded = CloneObjectRequest::decode(request.encode_to_vec().as_slice()).unwrap();
183 assert_eq!(decoded, request);
184 }
185
186 #[test]
187 fn object_link_wire_contract_keeps_link_and_target_distinct() {
188 let link = ObjectAddress {
189 tenant: "tenant".into(),
190 bucket: "bucket".into(),
191 path: "alias".into(),
192 };
193 let request = LinkObjectRequest {
194 link: Some(link.clone()),
195 target: Some(ObjectAddress {
196 tenant: "tenant".into(),
197 bucket: "bucket".into(),
198 path: "target".into(),
199 }),
200 command_id: "link-1".into(),
201 durability: Durability::Replicated as i32,
202 };
203 assert_eq!(
204 LinkObjectRequest::decode(request.encode_to_vec().as_slice()).unwrap(),
205 request
206 );
207 let unlink = UnlinkObjectRequest {
208 link: Some(link),
209 command_id: "unlink-1".into(),
210 durability: Durability::Local as i32,
211 };
212 assert_eq!(
213 UnlinkObjectRequest::decode(unlink.encode_to_vec().as_slice()).unwrap(),
214 unlink
215 );
216 }
217
218 #[test]
219 fn schema_keeps_removed_capabilities_out() {
220 let set = descriptors();
221 let object_methods = service(&set, "ObjectService")
222 .method
223 .iter()
224 .filter_map(|method| method.name.as_deref())
225 .collect::<Vec<_>>();
226 for required in [
227 "StartPut",
228 "Put",
229 "PutEnd",
230 "WatchPrefix",
231 "ListObjects",
232 "CloneObject",
233 "DeleteVersion",
234 "ListObjectVersions",
235 ] {
236 assert!(object_methods.contains(&required));
237 }
238 let start_put = service(&set, "ObjectService")
239 .method
240 .iter()
241 .find(|method| method.name.as_deref() == Some("StartPut"))
242 .unwrap();
243 assert_eq!(
244 start_put.input_type.as_deref(),
245 Some(".keldra.v1.PutHeader")
246 );
247 assert_eq!(
248 start_put.output_type.as_deref(),
249 Some(".keldra.v1.PutToken")
250 );
251 let put = service(&set, "ObjectService")
252 .method
253 .iter()
254 .find(|method| method.name.as_deref() == Some("Put"))
255 .unwrap();
256 assert_eq!(put.client_streaming, Some(true));
257 for removed in [
258 "UploadBlob",
259 "PublishObject",
260 "PutObject",
261 "ListPrefix",
262 "BeginTransaction",
263 "CommitTransaction",
264 "RegisterProgram",
265 ] {
266 assert!(!object_methods.contains(&removed));
267 }
268 let index_methods = service(&set, "IndexService")
269 .method
270 .iter()
271 .filter_map(|method| method.name.as_deref())
272 .collect::<Vec<_>>();
273 for required in [
274 "CreateIndex",
275 "UpdateIndex",
276 "GetIndex",
277 "ListIndices",
278 "DeleteIndex",
279 "QueryIndex",
280 ] {
281 assert!(index_methods.contains(&required));
282 }
283 assert_eq!(field_number(&set, "InvokeProgramRequest", "program"), 1);
284 assert_eq!(
285 field_number(
286 &set,
287 "InvokeProgramResponse",
288 "executor_nomination_log_index"
289 ),
290 4
291 );
292 assert_eq!(
293 field_number(&set, "InvokeProgramResponse", "commit_log_index"),
294 5
295 );
296 assert_eq!(
297 field_number(&set, "BucketPolicy", "immutable_path_prefixes"),
298 1
299 );
300 assert_eq!(
301 field_number(&set, "BucketPolicy", "program_only_path_prefixes"),
302 2
303 );
304 let put_header = message(&set, "PutHeader");
305 assert!(
306 put_header
307 .oneof_decl
308 .iter()
309 .any(|oneof| oneof.name.as_deref() == Some("operation"))
310 );
311 for operation in ["put", "put_if_absent", "put_if_version", "put_immutable"] {
312 assert_eq!(
313 put_header
314 .field
315 .iter()
316 .find(|field| field.name.as_deref() == Some(operation))
317 .and_then(|field| field.oneof_index),
318 Some(0)
319 );
320 }
321 assert!(
322 !set.file
323 .iter()
324 .flat_map(|file| &file.message_type)
325 .any(|message| message.name.as_deref() == Some("BlobRef"))
326 );
327 }
328
329 #[test]
330 fn accounting_schema_separates_logical_and_replica_inclusive_physical_usage() {
331 let set = descriptors();
332 for (message_name, field) in [
333 ("AccountingLogicalUsage", "billable_logical_bytes"),
334 (
335 "AccountingLogicalUsage",
336 "retained_non_billable_logical_bytes",
337 ),
338 ("AccountingLogicalUsage", "visible_file_count"),
339 ("ClusterPhysicalStorage", "live_payload_blob_bytes"),
340 ("ClusterPhysicalStorage", "garbage_payload_blob_bytes"),
341 ("ClusterPhysicalStorage", "payload_sst_bytes"),
342 ("ClusterPhysicalStorage", "metadata_index_sst_bytes"),
343 ("ClusterPhysicalStorage", "wal_bytes"),
344 ("ClusterPhysicalStorage", "active_node_count"),
345 ("ClusterPhysicalStorage", "reported_node_count"),
346 ("ClusterPhysicalStorage", "reported_storage_replica_count"),
347 ("ClusterLogicalFileCounts", "visible_file_count"),
348 ("ClusterLogicalFileCounts", "active_source_node_count"),
349 ("ClusterLogicalFileCounts", "reported_source_node_count"),
350 ("ClusterCapabilities", "logical_file_counts"),
351 ("AccountingSnapshot", "tenant_physical_bytes"),
352 ] {
353 assert!(
354 message(&set, message_name)
355 .field
356 .iter()
357 .any(|candidate| candidate.name.as_deref() == Some(field)),
358 "missing descriptor field {message_name}.{field}"
359 );
360 }
361 assert!(
362 !set.file
363 .iter()
364 .flat_map(|file| &file.message_type)
365 .flat_map(|message| &message.field)
366 .any(|field| matches!(
367 field.name.as_deref(),
368 Some("logical_stored_bytes" | "object_count")
369 ))
370 );
371 }
372
373 #[test]
374 fn generated_index_client_is_publicly_exposed() {
375 let _: Option<
376 super::v1::index_service_client::IndexServiceClient<tonic::transport::Channel>,
377 > = None;
378 }
379
380 #[test]
381 fn typed_json_fields_have_explicit_type_cardinality_and_capabilities() {
382 use super::v1::{
383 IndexField, IndexFieldCapability, IndexFieldCardinality, KeywordIndexField, index_field,
384 };
385
386 let field = IndexField {
387 name: "document_id".into(),
388 json_pointer: "/id".into(),
389 cardinality: IndexFieldCardinality::Single as i32,
390 capabilities: vec![IndexFieldCapability::Exact as i32],
391 field_type: Some(index_field::FieldType::Keyword(KeywordIndexField {})),
392 };
393
394 assert!(matches!(
395 field.field_type,
396 Some(index_field::FieldType::Keyword(_))
397 ));
398 assert_eq!(field.capabilities, [IndexFieldCapability::Exact as i32]);
399
400 let date = super::v1::DateIndexField {
401 strftime_pattern: "%Y-%m-%d".into(),
402 };
403 assert!(matches!(
404 index_field::FieldType::Date(date),
405 index_field::FieldType::Date(value) if value.strftime_pattern == "%Y-%m-%d"
406 ));
407
408 let set = descriptors();
409 assert!(
410 service(&set, "IndexService")
411 .method
412 .iter()
413 .any(|method| method.name.as_deref() == Some("ListIndices"))
414 );
415 assert_eq!(field_number(&set, "ListIndicesResponse", "indices"), 1);
416 assert!(
417 !service(&set, "IndexService")
418 .method
419 .iter()
420 .any(|method| method.name.as_deref() == Some("ListIndexes"))
421 );
422 assert!(
423 !set.file
424 .iter()
425 .flat_map(|file| &file.message_type)
426 .flat_map(|message| &message.field)
427 .any(|field| matches!(field.name.as_deref(), Some("fields_json" | "multi_valued")))
428 );
429 }
430
431 #[test]
432 fn typed_json_queries_expose_fielded_text_facets_and_aggregates() {
433 use super::v1::index_predicate_expression::Expression;
434 use super::v1::{
435 IndexAggregateOperation, IndexAggregateRequest, IndexAggregateResult, IndexFacetBucket,
436 IndexFacetRequest, IndexFacetResult, IndexPredicate, IndexPredicateConjunction,
437 IndexPredicateExpression, IndexPredicateOperator, IndexQueryHit, QueryIndexResponse,
438 TypedJsonIndexQuery,
439 };
440
441 let query = TypedJsonIndexQuery {
442 predicate: Some(IndexPredicateExpression {
443 expression: Some(Expression::Conjunction(IndexPredicateConjunction {
444 expressions: [
445 IndexPredicateOperator::FullText,
446 IndexPredicateOperator::Phrase,
447 ]
448 .into_iter()
449 .map(|operator| IndexPredicateExpression {
450 expression: Some(Expression::Predicate(IndexPredicate {
451 field: "summary".into(),
452 operator: operator as i32,
453 values_json: vec![br#""memory safety""#.to_vec()],
454 })),
455 })
456 .collect(),
457 })),
458 }),
459 order: Vec::new(),
460 facets: vec![IndexFacetRequest {
461 field: "ecosystem".into(),
462 limit: 10,
463 }],
464 aggregates: vec![IndexAggregateRequest {
465 field: "severity".into(),
466 operation: IndexAggregateOperation::Average as i32,
467 }],
468 };
469 let response = QueryIndexResponse {
470 hits: vec![IndexQueryHit {
471 address: None,
472 object_version: 7,
473 score: Some(0.75),
474 }],
475 next_page_token: Vec::new(),
476 freshness: None,
477 facet_results: vec![IndexFacetResult {
478 field: "ecosystem".into(),
479 buckets: vec![IndexFacetBucket {
480 value_json: br#""cargo""#.to_vec(),
481 count: 4,
482 }],
483 }],
484 aggregate_results: vec![IndexAggregateResult {
485 field: "severity".into(),
486 operation: IndexAggregateOperation::Average as i32,
487 value_json: Some(b"7.5".to_vec()),
488 contributing_count: 4,
489 }],
490 };
491
492 assert_eq!(query.facets[0].limit, 10);
493 assert!(matches!(
494 query.predicate.unwrap().expression,
495 Some(Expression::Conjunction(_))
496 ));
497 assert_eq!(response.hits[0].object_version, 7);
498 assert_eq!(response.aggregate_results[0].contributing_count, 4);
499
500 let set = descriptors();
501 assert_eq!(field_number(&set, "TypedJsonIndexQuery", "predicate"), 5);
502 assert_eq!(
503 field_number(&set, "MetadataFilterIndexQuery", "predicate"),
504 2
505 );
506 }
507
508 #[test]
509 fn predicate_expression_helpers_reject_empty_boolean_operators() {
510 use super::PredicateExpressionError;
511 use super::v1::index_predicate_expression::Expression;
512 use super::v1::{IndexPredicate, IndexPredicateExpression, IndexPredicateOperator};
513
514 let leaf = IndexPredicateExpression::leaf(IndexPredicate {
515 field: "status".into(),
516 operator: IndexPredicateOperator::Exists as i32,
517 values_json: Vec::new(),
518 });
519 assert!(matches!(
520 IndexPredicateExpression::all([leaf.clone()])
521 .unwrap()
522 .expression,
523 Some(Expression::Conjunction(_))
524 ));
525 assert!(matches!(
526 IndexPredicateExpression::any([leaf.clone()])
527 .unwrap()
528 .expression,
529 Some(Expression::Disjunction(_))
530 ));
531 assert!(matches!(
532 leaf.negated().expression,
533 Some(Expression::Negation(_))
534 ));
535 assert_eq!(
536 IndexPredicateExpression::all(Vec::new()).unwrap_err(),
537 PredicateExpressionError::EmptyConjunction
538 );
539 assert_eq!(
540 IndexPredicateExpression::any(Vec::new()).unwrap_err(),
541 PredicateExpressionError::EmptyDisjunction
542 );
543 }
544
545 #[test]
546 fn generated_personaldb_client_is_publicly_exposed() {
547 let _: Option<
548 super::v1::personal_db_service_client::PersonalDbServiceClient<
549 tonic::transport::Channel,
550 >,
551 > = None;
552
553 let set = descriptors();
554 let methods = service(&set, "PersonalDbService")
555 .method
556 .iter()
557 .filter_map(|method| method.name.as_deref())
558 .collect::<Vec<_>>();
559 for required in [
560 "CreateGroup",
561 "DescribeGroup",
562 "ListGroups",
563 "GrantGroupRole",
564 "RevokeGroupRole",
565 "AppendEntry",
566 "MaterializeProjection",
567 "CatchUp",
568 "RegisterSnapshot",
569 "GetSnapshot",
570 ] {
571 assert!(
572 methods.contains(&required),
573 "missing generated RPC descriptor {required}"
574 );
575 }
576 }
577
578 #[test]
579 fn object_surface_has_only_explicit_typed_mutations() {
580 use super::v1::{
581 BulkOperation, BulkPutIfVersionRequest, CreateBucketRequest, DeleteIfVersionRequest,
582 DeleteRequest, DeleteVersionRequest, DeleteVersionResponse, Durability,
583 ListObjectsRequest, ListObjectsResponse, ObjectAddress, ObjectVersioning, PutHeader,
584 PutIfVersionOperation, PutRequest, PutToken, bulk_operation, put_header,
585 };
586
587 let address = Some(ObjectAddress {
588 tenant: "acme".into(),
589 bucket: "objects".into(),
590 path: "one".into(),
591 });
592 let header = PutHeader {
593 address: address.clone(),
594 content_type: "application/json".into(),
595 command_id: "command-1".into(),
596 durability: Durability::Local as i32,
597 operation: Some(put_header::Operation::PutIfVersion(PutIfVersionOperation {
598 expected_version: 8,
599 })),
600 };
601 let frame = PutRequest {
602 token: Some(PutToken {
603 value: b"opaque".to_vec(),
604 expires_at: None,
605 }),
606 chunk: Vec::new(),
607 };
608 assert!(matches!(
609 header.operation,
610 Some(put_header::Operation::PutIfVersion(_))
611 ));
612 assert!(frame.chunk.is_empty());
613
614 let operations = [
615 bulk_operation::Operation::Put(Default::default()),
616 bulk_operation::Operation::PutIfAbsent(Default::default()),
617 bulk_operation::Operation::PutIfVersion(BulkPutIfVersionRequest::default()),
618 bulk_operation::Operation::PutImmutable(Default::default()),
619 bulk_operation::Operation::Delete(DeleteRequest {
620 address: address.clone(),
621 ..Default::default()
622 }),
623 bulk_operation::Operation::DeleteIfVersion(DeleteIfVersionRequest {
624 address: address.clone(),
625 expected_version: 8,
626 ..Default::default()
627 }),
628 ];
629 assert_eq!(
630 operations
631 .into_iter()
632 .map(|operation| BulkOperation {
633 operation: Some(operation),
634 })
635 .count(),
636 6
637 );
638
639 let current_head_delete = DeleteIfVersionRequest {
640 address: address.clone(),
641 expected_version: 8,
642 ..Default::default()
643 };
644 let retained_version_delete = DeleteVersionRequest {
645 address,
646 version: 7,
647 ..Default::default()
648 };
649 assert_eq!(current_head_delete.expected_version, 8);
650 assert_eq!(retained_version_delete.version, 7);
651 let replaced_current = DeleteVersionResponse {
652 deleted: true,
653 replacement_tombstone_version: Some(9),
654 };
655 assert_eq!(replaced_current.replacement_tombstone_version, Some(9));
656 assert_eq!(
657 CreateBucketRequest::default().versioning,
658 ObjectVersioning::Unversioned as i32
659 );
660
661 let list_request = ListObjectsRequest {
662 tenant: "acme".into(),
663 bucket: "objects".into(),
664 prefix: "reports/".into(),
665 start_after: Some("reports/2025.json".into()),
666 limit: 100,
667 };
668 let list_response = ListObjectsResponse {
669 paths: vec!["reports/2026.json".into()],
670 has_more: false,
671 };
672 assert_eq!(
673 list_request.start_after.as_deref(),
674 Some("reports/2025.json")
675 );
676 assert_eq!(list_response.paths, vec!["reports/2026.json".to_owned()]);
677 }
678
679 #[test]
680 fn authorization_wire_types_preserve_scope_and_typed_unions() {
681 use super::v1::{
682 AnyUsersetSelector, AtLeastRevision, AuthzConsistency, AuthzScope, DirectRelation,
683 InheritRule, NamespaceDefinition, ObjectRef, Permission, PermissionRule,
684 PutSchemaRequest, RelationDefinition, SubjectSelector, Userset, authz_consistency,
685 object_ref, permission_rule, relation_definition, subject, subject_selector,
686 };
687
688 let account = ObjectRef {
689 namespace: "account".into(),
690 id: Some(object_ref::Id::OpaqueId("acme".into())),
691 };
692 let members = Userset {
693 object: Some(account),
694 relation: "member".into(),
695 };
696 let schema = NamespaceDefinition {
697 name: "ledger".into(),
698 relations: vec![
699 RelationDefinition {
700 name: "reader".into(),
701 kind: Some(relation_definition::Kind::Direct(DirectRelation {
702 allowed_subjects: vec![SubjectSelector {
703 selector: Some(subject_selector::Selector::AnyUserset(
704 AnyUsersetSelector {
705 namespace: "account".into(),
706 relation: "member".into(),
707 },
708 )),
709 }],
710 })),
711 },
712 RelationDefinition {
713 name: "read".into(),
714 kind: Some(relation_definition::Kind::Permission(Permission {
715 rules: vec![PermissionRule {
716 rule: Some(permission_rule::Rule::Inherit(InheritRule {
717 relation: "reader".into(),
718 })),
719 }],
720 })),
721 },
722 ],
723 };
724 let subject = super::v1::Subject {
725 kind: Some(subject::Kind::Userset(members)),
726 };
727 let publication = PutSchemaRequest {
728 schema_id: "acme".into(),
729 namespaces: vec![schema],
730 };
731 let consistency = AuthzConsistency {
732 requirement: Some(authz_consistency::Requirement::AtLeast(AtLeastRevision {
733 revision: 42,
734 })),
735 };
736 let system_scope = AuthzScope {
737 storage_tenant: "keldra-internal".into(),
738 realm: "_keldra/system".into(),
739 };
740
741 assert_eq!(publication.namespaces[0].relations.len(), 2);
742 assert!(matches!(subject.kind, Some(subject::Kind::Userset(_))));
743 assert!(matches!(
744 consistency.requirement,
745 Some(authz_consistency::Requirement::AtLeast(AtLeastRevision {
746 revision: 42
747 }))
748 ));
749 assert_eq!(system_scope.realm, "_keldra/system");
750 }
751
752 #[test]
753 fn tuple_mutation_and_batch_checks_share_request_scope() {
754 use super::v1::{
755 AuthzScope, CheckPermissionsRequest, MutateTuplesRequest, ObjectRef, PermissionCheck,
756 RelationTuple, Subject, TupleMutation, authz_consistency, object_ref, subject,
757 tuple_mutation,
758 };
759
760 let scope = AuthzScope {
761 storage_tenant: "acme".into(),
762 realm: "default".into(),
763 };
764 let ledger = ObjectRef {
765 namespace: "ledger".into(),
766 id: Some(object_ref::Id::OpaqueId("main".into())),
767 };
768 let alice = Subject {
769 kind: Some(subject::Kind::Object(ObjectRef {
770 namespace: "user".into(),
771 id: Some(object_ref::Id::OpaqueId("alice".into())),
772 })),
773 };
774 let tuple = RelationTuple {
775 object: Some(ledger.clone()),
776 relation: "reader".into(),
777 subject: Some(alice.clone()),
778 };
779 let mutation = MutateTuplesRequest {
780 scope: Some(scope.clone()),
781 operation_id: "grant-alice".into(),
782 expected_revision: Some(41),
783 mutations: vec![TupleMutation {
784 operation: Some(tuple_mutation::Operation::Add(tuple)),
785 }],
786 };
787 let checks = CheckPermissionsRequest {
788 scope: Some(scope),
789 checks: vec![PermissionCheck {
790 subject: Some(alice),
791 object: Some(ledger),
792 relation: "read".into(),
793 }],
794 consistency: Some(super::v1::AuthzConsistency {
795 requirement: Some(authz_consistency::Requirement::Exact(
796 super::v1::ExactRevision { revision: 42 },
797 )),
798 }),
799 };
800
801 assert_eq!(mutation.expected_revision, Some(41));
802 assert_eq!(mutation.mutations.len(), 1);
803 assert_eq!(checks.checks.len(), 1);
804 assert!(matches!(
805 checks
806 .consistency
807 .and_then(|consistency| consistency.requirement),
808 Some(authz_consistency::Requirement::Exact(_))
809 ));
810 }
811}