use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::ids::{CardUid, ModelId, ProviderDeploymentName};
const MAX_IDENTIFIER_LEN: usize = 63;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum PermissionScopeError {
#[error("permission scope {field} is not a valid identifier: {value:?}")]
InvalidIdentifier {
field: &'static str,
value: String,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum PermissionScope {
All,
Bifrost(BifrostPermissionScope),
Gateway(GatewayAccess),
Verifier(CardUid),
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum GatewayAccess {
Deployment {
deployment: ProviderDeploymentName,
},
Model {
deployment: ProviderDeploymentName,
model: ModelId,
},
}
impl GatewayAccess {
#[must_use]
pub fn covers(&self, required: &Self) -> bool {
match (self, required) {
(
Self::Deployment { deployment },
Self::Deployment {
deployment: required,
}
| Self::Model {
deployment: required,
..
},
) => deployment == required,
(Self::Model { .. }, Self::Model { .. }) => self == required,
(Self::Model { .. }, Self::Deployment { .. }) => false,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum BifrostPermissionScope {
Schema(BifrostSchemaScope),
Table(BifrostTableScope),
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct BifrostSchemaScope {
pub catalog: String,
pub schema: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct BifrostTableScope {
pub catalog: String,
pub schema: String,
pub table_uid: Uuid,
}
impl PermissionScope {
#[must_use]
pub fn covers(&self, required: &Self) -> bool {
match (self, required) {
(Self::All, _) => true,
(Self::Bifrost(granted), Self::Bifrost(required)) => granted.covers(required),
(Self::Gateway(granted), Self::Gateway(required)) => granted.covers(required),
(Self::Verifier(granted), Self::Verifier(required)) => granted == required,
(Self::Bifrost(_) | Self::Gateway(_) | Self::Verifier(_), _) => false,
}
}
#[must_use]
pub const fn is_bifrost(&self) -> bool {
matches!(self, Self::Bifrost(_))
}
#[must_use]
pub const fn is_gateway(&self) -> bool {
matches!(self, Self::Gateway(_))
}
pub fn validate(&self) -> Result<(), PermissionScopeError> {
match self {
Self::All | Self::Gateway(_) | Self::Verifier(_) => Ok(()),
Self::Bifrost(scope) => scope.validate(),
}
}
}
impl BifrostPermissionScope {
#[must_use]
pub fn covers(&self, required: &Self) -> bool {
match (self, required) {
(Self::Schema(granted), Self::Schema(required)) => granted == required,
(Self::Schema(granted), Self::Table(required)) => {
granted.catalog == required.catalog && granted.schema == required.schema
}
(Self::Table(granted), Self::Table(required)) => granted == required,
(Self::Table(_), Self::Schema(_)) => false,
}
}
pub fn validate(&self) -> Result<(), PermissionScopeError> {
let (catalog, schema) = match self {
Self::Schema(scope) => (&scope.catalog, &scope.schema),
Self::Table(scope) => (&scope.catalog, &scope.schema),
};
validate_identifier("catalog", catalog)?;
validate_identifier("schema", schema)
}
#[must_use]
pub fn catalog(&self) -> &str {
match self {
Self::Schema(scope) => &scope.catalog,
Self::Table(scope) => &scope.catalog,
}
}
#[must_use]
pub fn schema(&self) -> &str {
match self {
Self::Schema(scope) => &scope.schema,
Self::Table(scope) => &scope.schema,
}
}
}
fn validate_identifier(field: &'static str, value: &str) -> Result<(), PermissionScopeError> {
let valid = !value.is_empty()
&& value.len() <= MAX_IDENTIFIER_LEN
&& value.chars().all(|character| {
character.is_ascii_alphanumeric() || character == '_' || character == '-'
});
if valid {
Ok(())
} else {
Err(PermissionScopeError::InvalidIdentifier {
field,
value: value.to_owned(),
})
}
}
#[cfg(test)]
mod tests {
use super::{
BifrostPermissionScope, BifrostSchemaScope, BifrostTableScope, PermissionScope,
PermissionScopeError,
};
use uuid::Uuid;
fn table(schema: &str, uid: Uuid) -> PermissionScope {
PermissionScope::Bifrost(BifrostPermissionScope::Table(BifrostTableScope {
catalog: "vala".to_owned(),
schema: schema.to_owned(),
table_uid: uid,
}))
}
fn schema(schema: &str) -> PermissionScope {
PermissionScope::Bifrost(BifrostPermissionScope::Schema(BifrostSchemaScope {
catalog: "vala".to_owned(),
schema: schema.to_owned(),
}))
}
#[test]
fn all_covers_every_object_and_is_covered_by_nothing_narrower() {
let uid = Uuid::from_u128(1);
assert!(PermissionScope::All.covers(&PermissionScope::All));
assert!(PermissionScope::All.covers(&schema("logs")));
assert!(PermissionScope::All.covers(&table("logs", uid)));
assert!(!schema("logs").covers(&PermissionScope::All));
assert!(!table("logs", uid).covers(&PermissionScope::All));
}
#[test]
fn schema_scope_covers_every_table_in_that_exact_schema() {
let one = Uuid::from_u128(1);
let two = Uuid::from_u128(2);
assert!(schema("logs").covers(&table("logs", one)));
assert!(schema("logs").covers(&table("logs", two)));
assert!(!schema("logs").covers(&table("traces", one)));
assert!(!schema("logs").covers(&schema("traces")));
}
#[test]
fn table_scope_covers_only_the_exact_uid() {
let one = Uuid::from_u128(1);
let two = Uuid::from_u128(2);
assert!(table("traces", one).covers(&table("traces", one)));
assert!(!table("traces", one).covers(&table("traces", two)));
assert!(!table("traces", one).covers(&schema("traces")));
}
#[test]
fn scope_json_matches_the_approved_projection() {
let uid = Uuid::from_u128(0x1234);
assert_eq!(
serde_json::to_value(PermissionScope::All).expect("all serializes"),
serde_json::json!("all")
);
assert_eq!(
serde_json::to_value(schema("logs")).expect("schema scope serializes"),
serde_json::json!({"bifrost": {"schema": {"catalog": "vala", "schema": "logs"}}})
);
assert_eq!(
serde_json::to_value(table("traces", uid)).expect("table scope serializes"),
serde_json::json!({
"bifrost": {"table": {
"catalog": "vala",
"schema": "traces",
"table_uid": uid.to_string(),
}}
})
);
}
#[test]
fn malformed_identities_are_rejected() {
assert!(PermissionScope::All.validate().is_ok());
assert!(schema("logs").validate().is_ok());
assert_eq!(
schema("").validate(),
Err(PermissionScopeError::InvalidIdentifier {
field: "schema",
value: String::new(),
})
);
assert!(
PermissionScope::Bifrost(BifrostPermissionScope::Schema(BifrostSchemaScope {
catalog: "vala.logs".to_owned(),
schema: "logs".to_owned(),
}))
.validate()
.is_err(),
"a flattened namespace is not a catalog identifier"
);
assert!(
schema(&"x".repeat(64)).validate().is_err(),
"an over-long schema segment is rejected"
);
}
#[test]
fn gateway_scope_covers_deployment_models_and_exact_pairs() {
use super::GatewayAccess;
use crate::ids::{ModelId, ProviderDeploymentName};
let provider = |name: &str| {
PermissionScope::Gateway(GatewayAccess::Deployment {
deployment: ProviderDeploymentName::new(name).expect("valid deployment"),
})
};
let model = |name: &str, model: &str| {
PermissionScope::Gateway(GatewayAccess::Model {
deployment: ProviderDeploymentName::new(name).expect("valid deployment"),
model: ModelId::new(model).expect("valid model"),
})
};
assert!(provider("openai").covers(&model("openai", "gpt-4o")));
assert!(provider("openai").covers(&provider("openai")));
assert!(!provider("openai").covers(&model("anthropic", "claude")));
assert!(model("openai", "gpt-4o").covers(&model("openai", "gpt-4o")));
assert!(!model("openai", "gpt-4o").covers(&model("openai", "gpt-4.1")));
assert!(!model("openai", "gpt-4o").covers(&provider("openai")));
assert!(!provider("openai").covers(&PermissionScope::All));
assert!(!provider("openai").covers(&schema("logs")));
assert!(!schema("logs").covers(&provider("openai")));
assert!(PermissionScope::All.covers(&model("openai", "gpt-4o")));
assert_eq!(
serde_json::to_value(model("openai", "gpt-4o")).expect("scope serializes"),
serde_json::json!({"gateway": {"model": {"deployment": "openai", "model": "gpt-4o"}}})
);
assert!(
serde_json::from_value::<PermissionScope>(
serde_json::json!({"gateway": {"deployment": {"deployment": "OpenAI"}}})
)
.is_err()
);
assert!(
serde_json::from_value::<PermissionScope>(
serde_json::json!({"gateway": {"provider": {"provider": "openai"}}})
)
.is_err(),
"the retired provider scope does not decode"
);
}
#[test]
fn malformed_table_identity_is_rejected_at_decode() {
let error = serde_json::from_value::<PermissionScope>(serde_json::json!({
"bifrost": {"table": {"catalog": "vala", "schema": "traces", "table_uid": "not-a-uid"}}
}))
.expect_err("a non-UUID table identity does not decode");
assert!(error.to_string().contains("UUID") || error.to_string().contains("uuid"));
}
}