Skip to main content

keldra_api/
lib.rs

1//! Generated Rust types for the Keldra 0.13 gRPC API.
2
3pub mod v1 {
4    tonic::include_proto!("keldra.v1");
5}
6
7/// A Boolean predicate expression rejected before it is sent to Keldra.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum PredicateExpressionError {
10    EmptyConjunction,
11    EmptyDisjunction,
12}
13
14impl std::fmt::Display for PredicateExpressionError {
15    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16        match self {
17            Self::EmptyConjunction => {
18                formatter.write_str("a predicate conjunction requires at least one child")
19            }
20            Self::EmptyDisjunction => {
21                formatter.write_str("a predicate disjunction requires at least one child")
22            }
23        }
24    }
25}
26
27impl std::error::Error for PredicateExpressionError {}
28
29impl v1::IndexPredicateExpression {
30    /// Construct one leaf expression.
31    pub fn leaf(predicate: v1::IndexPredicate) -> Self {
32        Self {
33            expression: Some(v1::index_predicate_expression::Expression::Predicate(
34                predicate,
35            )),
36        }
37    }
38
39    /// Construct a non-empty conjunction.
40    pub fn all(
41        expressions: impl IntoIterator<Item = Self>,
42    ) -> Result<Self, PredicateExpressionError> {
43        let expressions = expressions.into_iter().collect::<Vec<_>>();
44        if expressions.is_empty() {
45            return Err(PredicateExpressionError::EmptyConjunction);
46        }
47        Ok(Self {
48            expression: Some(v1::index_predicate_expression::Expression::Conjunction(
49                v1::IndexPredicateConjunction { expressions },
50            )),
51        })
52    }
53
54    /// Construct a non-empty disjunction.
55    pub fn any(
56        expressions: impl IntoIterator<Item = Self>,
57    ) -> Result<Self, PredicateExpressionError> {
58        let expressions = expressions.into_iter().collect::<Vec<_>>();
59        if expressions.is_empty() {
60            return Err(PredicateExpressionError::EmptyDisjunction);
61        }
62        Ok(Self {
63            expression: Some(v1::index_predicate_expression::Expression::Disjunction(
64                v1::IndexPredicateDisjunction { expressions },
65            )),
66        })
67    }
68
69    /// Negate this complete expression.
70    pub fn negated(self) -> Self {
71        Self {
72            expression: Some(v1::index_predicate_expression::Expression::Negation(
73                Box::new(v1::IndexPredicateNegation {
74                    expression: Some(Box::new(self)),
75                }),
76            )),
77        }
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::v1::{DeletedObject, NeverExisted, ObjectHead, PresentObject, object_head};
84
85    #[test]
86    fn exact_path_states_are_distinct() {
87        let states = [
88            ObjectHead {
89                state: Some(object_head::State::Present(PresentObject {
90                    version: 7,
91                    content_hash: vec![7; 32],
92                    content_length: 99,
93                    content_type: String::new(),
94                })),
95            },
96            ObjectHead {
97                state: Some(object_head::State::Deleted(DeletedObject { version: 8 })),
98            },
99            ObjectHead {
100                state: Some(object_head::State::NeverExisted(NeverExisted {})),
101            },
102        ];
103
104        assert!(matches!(
105            &states[0].state,
106            Some(object_head::State::Present(_))
107        ));
108        assert!(matches!(
109            &states[1].state,
110            Some(object_head::State::Deleted(_))
111        ));
112        assert!(matches!(
113            &states[2].state,
114            Some(object_head::State::NeverExisted(_))
115        ));
116    }
117
118    #[test]
119    fn schema_keeps_removed_capabilities_out() {
120        let schema = include_str!("../proto/keldra.proto").to_ascii_lowercase();
121        for forbidden in [
122            "rpc uploadblob",
123            "rpc publishobject",
124            "rpc putobject",
125            "message blobref",
126            "rpc listprefix",
127            "rpc begintransaction",
128            "rpc committransaction",
129            "personaldb",
130        ] {
131            assert!(!schema.contains(forbidden), "schema contains `{forbidden}`");
132        }
133
134        assert!(schema.contains("executor_nomination_log_index"));
135        assert!(schema.contains("commit_log_index"));
136        assert!(schema.contains("immutable_path_prefixes"));
137        assert!(schema.contains("program_only_path_prefixes"));
138        assert!(!schema.contains("rpc registerprogram"));
139        assert!(!schema.contains("message registerprogram"));
140        assert!(schema.contains("objectaddress program"));
141        assert!(schema.contains("_keldra/programs/{name}@{version}"));
142        assert!(schema.contains("rpc startput(putheader) returns (puttoken)"));
143        assert!(schema.contains("rpc put(stream putrequest) returns (puttoken)"));
144        assert!(schema.contains("rpc putend(puttoken) returns (mutationreceipt)"));
145        for rpc in [
146            "rpc createindex(createindexrequest)",
147            "rpc updateindex(updateindexrequest)",
148            "rpc getindex(getindexrequest)",
149            "rpc listindices(listindicesrequest)",
150            "rpc deleteindex(deleteindexrequest)",
151            "rpc queryindex(queryindexrequest)",
152        ] {
153            assert!(schema.contains(rpc), "schema is missing `{rpc}`");
154        }
155        assert!(schema.contains("index_kind_tensor"));
156        assert!(schema.contains("tensorindexspec tensor"));
157        assert!(schema.contains("tensorindexquery tensor"));
158
159        for rpc in [
160            "rpc exchangeclientcredentials",
161            "rpc provisiontenant",
162            "rpc createapplication",
163            "rpc rotateapplicationcredential",
164            "rpc disableapplicationcredential",
165            "rpc createbucket",
166            "rpc grantapplicationrole",
167            "rpc revokeapplicationrole",
168            "rpc putschema",
169            "rpc bindschema",
170            "rpc getbinding",
171            "rpc getschema",
172            "rpc mutatetuples",
173            "rpc readtuples",
174            "rpc checkpermission",
175            "rpc checkpermissions",
176            "rpc watchprefix",
177            "rpc listobjects",
178            "rpc deleteversion",
179            "rpc listobjectversions",
180            "rpc setbucketversioning",
181        ] {
182            assert!(schema.contains(rpc), "schema is missing `{rpc}`");
183        }
184        for forbidden in [
185            "rpc createrealm",
186            "rpc deleterealm",
187            "rpc applyschema",
188            "zookie",
189            "caveat",
190            "publication_metadata",
191            "insecure_no_auth",
192            "api_token",
193        ] {
194            assert!(!schema.contains(forbidden), "schema contains `{forbidden}`");
195        }
196    }
197
198    #[test]
199    fn generated_index_client_is_publicly_exposed() {
200        let _: Option<
201            super::v1::index_service_client::IndexServiceClient<tonic::transport::Channel>,
202        > = None;
203    }
204
205    #[test]
206    fn typed_json_fields_have_explicit_type_cardinality_and_capabilities() {
207        use super::v1::{
208            IndexField, IndexFieldCapability, IndexFieldCardinality, KeywordIndexField, index_field,
209        };
210
211        let field = IndexField {
212            name: "document_id".into(),
213            json_pointer: "/id".into(),
214            cardinality: IndexFieldCardinality::Single as i32,
215            capabilities: vec![IndexFieldCapability::Exact as i32],
216            field_type: Some(index_field::FieldType::Keyword(KeywordIndexField {})),
217        };
218
219        assert!(matches!(
220            field.field_type,
221            Some(index_field::FieldType::Keyword(_))
222        ));
223        assert_eq!(field.capabilities, [IndexFieldCapability::Exact as i32]);
224
225        let schema = include_str!("../proto/keldra.proto").to_ascii_lowercase();
226        assert!(schema.contains("rpc listindices(listindicesrequest)"));
227        assert!(schema.contains("repeated indexdefinition indices = 1"));
228        assert!(!schema.contains("listindexes"));
229        assert!(!schema.contains("fields_json"));
230        assert!(!schema.contains("bool multi_valued"));
231    }
232
233    #[test]
234    fn typed_json_queries_expose_fielded_text_facets_and_aggregates() {
235        use super::v1::index_predicate_expression::Expression;
236        use super::v1::{
237            IndexAggregateOperation, IndexAggregateRequest, IndexAggregateResult, IndexFacetBucket,
238            IndexFacetRequest, IndexFacetResult, IndexPredicate, IndexPredicateConjunction,
239            IndexPredicateExpression, IndexPredicateOperator, IndexQueryHit, QueryIndexResponse,
240            TypedJsonIndexQuery,
241        };
242
243        let query = TypedJsonIndexQuery {
244            predicate: Some(IndexPredicateExpression {
245                expression: Some(Expression::Conjunction(IndexPredicateConjunction {
246                    expressions: [
247                        IndexPredicateOperator::FullText,
248                        IndexPredicateOperator::Phrase,
249                    ]
250                    .into_iter()
251                    .map(|operator| IndexPredicateExpression {
252                        expression: Some(Expression::Predicate(IndexPredicate {
253                            field: "summary".into(),
254                            operator: operator as i32,
255                            values_json: vec![br#""memory safety""#.to_vec()],
256                        })),
257                    })
258                    .collect(),
259                })),
260            }),
261            order: Vec::new(),
262            facets: vec![IndexFacetRequest {
263                field: "ecosystem".into(),
264                limit: 10,
265            }],
266            aggregates: vec![IndexAggregateRequest {
267                field: "severity".into(),
268                operation: IndexAggregateOperation::Average as i32,
269            }],
270        };
271        let response = QueryIndexResponse {
272            hits: vec![IndexQueryHit {
273                address: None,
274                object_version: 7,
275                score: Some(0.75),
276            }],
277            next_page_token: Vec::new(),
278            freshness: None,
279            facet_results: vec![IndexFacetResult {
280                field: "ecosystem".into(),
281                buckets: vec![IndexFacetBucket {
282                    value_json: br#""cargo""#.to_vec(),
283                    count: 4,
284                }],
285            }],
286            aggregate_results: vec![IndexAggregateResult {
287                field: "severity".into(),
288                operation: IndexAggregateOperation::Average as i32,
289                value_json: Some(b"7.5".to_vec()),
290                contributing_count: 4,
291            }],
292        };
293
294        assert_eq!(query.facets[0].limit, 10);
295        assert!(matches!(
296            query.predicate.unwrap().expression,
297            Some(Expression::Conjunction(_))
298        ));
299        assert_eq!(response.hits[0].object_version, 7);
300        assert_eq!(response.aggregate_results[0].contributing_count, 4);
301
302        let schema = include_str!("../proto/keldra.proto").to_ascii_lowercase();
303        assert!(!schema.contains("repeated indexpredicate predicates"));
304        assert!(schema.contains("indexpredicateexpression predicate = 5"));
305        assert!(schema.contains("indexpredicateexpression predicate = 2"));
306    }
307
308    #[test]
309    fn predicate_expression_helpers_reject_empty_boolean_operators() {
310        use super::PredicateExpressionError;
311        use super::v1::index_predicate_expression::Expression;
312        use super::v1::{IndexPredicate, IndexPredicateExpression, IndexPredicateOperator};
313
314        let leaf = IndexPredicateExpression::leaf(IndexPredicate {
315            field: "status".into(),
316            operator: IndexPredicateOperator::Exists as i32,
317            values_json: Vec::new(),
318        });
319        assert!(matches!(
320            IndexPredicateExpression::all([leaf.clone()])
321                .unwrap()
322                .expression,
323            Some(Expression::Conjunction(_))
324        ));
325        assert!(matches!(
326            IndexPredicateExpression::any([leaf.clone()])
327                .unwrap()
328                .expression,
329            Some(Expression::Disjunction(_))
330        ));
331        assert!(matches!(
332            leaf.negated().expression,
333            Some(Expression::Negation(_))
334        ));
335        assert_eq!(
336            IndexPredicateExpression::all(Vec::new()).unwrap_err(),
337            PredicateExpressionError::EmptyConjunction
338        );
339        assert_eq!(
340            IndexPredicateExpression::any(Vec::new()).unwrap_err(),
341            PredicateExpressionError::EmptyDisjunction
342        );
343    }
344
345    #[test]
346    fn generated_personaldb_client_is_publicly_exposed() {
347        let _: Option<
348            super::v1::personal_db_service_client::PersonalDbServiceClient<
349                tonic::transport::Channel,
350            >,
351        > = None;
352
353        let schema = include_str!("../proto/personaldb.proto").to_ascii_lowercase();
354        for rpc in [
355            "rpc creategroup(",
356            "rpc describegroup(",
357            "rpc listgroups(",
358            "rpc grantgrouprole(",
359            "rpc revokegrouprole(",
360            "rpc appendentry(",
361            "rpc materializeprojection(",
362            "rpc catchup(",
363            "rpc registersnapshot(",
364            "rpc getsnapshot(",
365        ] {
366            assert!(schema.contains(rpc), "PersonalDB schema is missing `{rpc}`");
367        }
368    }
369
370    #[test]
371    fn object_surface_has_only_explicit_typed_mutations() {
372        use super::v1::{
373            BulkOperation, BulkPutIfVersionRequest, CreateBucketRequest, DeleteIfVersionRequest,
374            DeleteRequest, DeleteVersionRequest, DeleteVersionResponse, Durability,
375            ListObjectsRequest, ListObjectsResponse, ObjectAddress, ObjectVersioning, PutHeader,
376            PutIfVersionOperation, PutRequest, PutToken, bulk_operation, put_header,
377        };
378
379        let address = Some(ObjectAddress {
380            tenant: "acme".into(),
381            bucket: "objects".into(),
382            path: "one".into(),
383        });
384        let header = PutHeader {
385            address: address.clone(),
386            content_type: "application/json".into(),
387            command_id: "command-1".into(),
388            durability: Durability::Local as i32,
389            operation: Some(put_header::Operation::PutIfVersion(PutIfVersionOperation {
390                expected_version: 8,
391            })),
392        };
393        let frame = PutRequest {
394            token: Some(PutToken {
395                value: b"opaque".to_vec(),
396                expires_at: None,
397            }),
398            chunk: Vec::new(),
399        };
400        assert!(matches!(
401            header.operation,
402            Some(put_header::Operation::PutIfVersion(_))
403        ));
404        assert!(frame.chunk.is_empty());
405
406        let operations = [
407            bulk_operation::Operation::Put(Default::default()),
408            bulk_operation::Operation::PutIfAbsent(Default::default()),
409            bulk_operation::Operation::PutIfVersion(BulkPutIfVersionRequest::default()),
410            bulk_operation::Operation::PutImmutable(Default::default()),
411            bulk_operation::Operation::Delete(DeleteRequest {
412                address: address.clone(),
413                ..Default::default()
414            }),
415            bulk_operation::Operation::DeleteIfVersion(DeleteIfVersionRequest {
416                address: address.clone(),
417                expected_version: 8,
418                ..Default::default()
419            }),
420        ];
421        assert_eq!(
422            operations
423                .into_iter()
424                .map(|operation| BulkOperation {
425                    operation: Some(operation),
426                })
427                .count(),
428            6
429        );
430
431        let current_head_delete = DeleteIfVersionRequest {
432            address: address.clone(),
433            expected_version: 8,
434            ..Default::default()
435        };
436        let retained_version_delete = DeleteVersionRequest {
437            address,
438            version: 7,
439            ..Default::default()
440        };
441        assert_eq!(current_head_delete.expected_version, 8);
442        assert_eq!(retained_version_delete.version, 7);
443        let replaced_current = DeleteVersionResponse {
444            deleted: true,
445            replacement_tombstone_version: Some(9),
446        };
447        assert_eq!(replaced_current.replacement_tombstone_version, Some(9));
448        assert_eq!(
449            CreateBucketRequest::default().versioning,
450            ObjectVersioning::Unversioned as i32
451        );
452
453        let list_request = ListObjectsRequest {
454            tenant: "acme".into(),
455            bucket: "objects".into(),
456            prefix: "reports/".into(),
457            start_after: Some("reports/2025.json".into()),
458            limit: 100,
459        };
460        let list_response = ListObjectsResponse {
461            paths: vec!["reports/2026.json".into()],
462            has_more: false,
463        };
464        assert_eq!(
465            list_request.start_after.as_deref(),
466            Some("reports/2025.json")
467        );
468        assert_eq!(list_response.paths, vec!["reports/2026.json".to_owned()]);
469    }
470
471    #[test]
472    fn authorization_wire_types_preserve_scope_and_typed_unions() {
473        use super::v1::{
474            AnyUsersetSelector, AtLeastRevision, AuthzConsistency, AuthzScope, DirectRelation,
475            InheritRule, NamespaceDefinition, ObjectRef, Permission, PermissionRule,
476            PutSchemaRequest, RelationDefinition, SubjectSelector, Userset, authz_consistency,
477            object_ref, permission_rule, relation_definition, subject, subject_selector,
478        };
479
480        let account = ObjectRef {
481            namespace: "account".into(),
482            id: Some(object_ref::Id::OpaqueId("acme".into())),
483        };
484        let members = Userset {
485            object: Some(account),
486            relation: "member".into(),
487        };
488        let schema = NamespaceDefinition {
489            name: "ledger".into(),
490            relations: vec![
491                RelationDefinition {
492                    name: "reader".into(),
493                    kind: Some(relation_definition::Kind::Direct(DirectRelation {
494                        allowed_subjects: vec![SubjectSelector {
495                            selector: Some(subject_selector::Selector::AnyUserset(
496                                AnyUsersetSelector {
497                                    namespace: "account".into(),
498                                    relation: "member".into(),
499                                },
500                            )),
501                        }],
502                    })),
503                },
504                RelationDefinition {
505                    name: "read".into(),
506                    kind: Some(relation_definition::Kind::Permission(Permission {
507                        rules: vec![PermissionRule {
508                            rule: Some(permission_rule::Rule::Inherit(InheritRule {
509                                relation: "reader".into(),
510                            })),
511                        }],
512                    })),
513                },
514            ],
515        };
516        let subject = super::v1::Subject {
517            kind: Some(subject::Kind::Userset(members)),
518        };
519        let publication = PutSchemaRequest {
520            schema_id: "acme".into(),
521            namespaces: vec![schema],
522        };
523        let consistency = AuthzConsistency {
524            requirement: Some(authz_consistency::Requirement::AtLeast(AtLeastRevision {
525                revision: 42,
526            })),
527        };
528        let system_scope = AuthzScope {
529            storage_tenant: "keldra-internal".into(),
530            realm: "_keldra/system".into(),
531        };
532
533        assert_eq!(publication.namespaces[0].relations.len(), 2);
534        assert!(matches!(subject.kind, Some(subject::Kind::Userset(_))));
535        assert!(matches!(
536            consistency.requirement,
537            Some(authz_consistency::Requirement::AtLeast(AtLeastRevision {
538                revision: 42
539            }))
540        ));
541        assert_eq!(system_scope.realm, "_keldra/system");
542    }
543
544    #[test]
545    fn tuple_mutation_and_batch_checks_share_request_scope() {
546        use super::v1::{
547            AuthzScope, CheckPermissionsRequest, MutateTuplesRequest, ObjectRef, PermissionCheck,
548            RelationTuple, Subject, TupleMutation, authz_consistency, object_ref, subject,
549            tuple_mutation,
550        };
551
552        let scope = AuthzScope {
553            storage_tenant: "acme".into(),
554            realm: "default".into(),
555        };
556        let ledger = ObjectRef {
557            namespace: "ledger".into(),
558            id: Some(object_ref::Id::OpaqueId("main".into())),
559        };
560        let alice = Subject {
561            kind: Some(subject::Kind::Object(ObjectRef {
562                namespace: "user".into(),
563                id: Some(object_ref::Id::OpaqueId("alice".into())),
564            })),
565        };
566        let tuple = RelationTuple {
567            object: Some(ledger.clone()),
568            relation: "reader".into(),
569            subject: Some(alice.clone()),
570        };
571        let mutation = MutateTuplesRequest {
572            scope: Some(scope.clone()),
573            operation_id: "grant-alice".into(),
574            expected_revision: Some(41),
575            mutations: vec![TupleMutation {
576                operation: Some(tuple_mutation::Operation::Add(tuple)),
577            }],
578        };
579        let checks = CheckPermissionsRequest {
580            scope: Some(scope),
581            checks: vec![PermissionCheck {
582                subject: Some(alice),
583                object: Some(ledger),
584                relation: "read".into(),
585            }],
586            consistency: Some(super::v1::AuthzConsistency {
587                requirement: Some(authz_consistency::Requirement::Exact(
588                    super::v1::ExactRevision { revision: 42 },
589                )),
590            }),
591        };
592
593        assert_eq!(mutation.expected_revision, Some(41));
594        assert_eq!(mutation.mutations.len(), 1);
595        assert_eq!(checks.checks.len(), 1);
596        assert!(matches!(
597            checks
598                .consistency
599                .and_then(|consistency| consistency.requirement),
600            Some(authz_consistency::Requirement::Exact(_))
601        ));
602    }
603}