1pub mod v1 {
4 tonic::include_proto!("keldra.v1");
5}
6
7#[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 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 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 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 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}