use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::sync::Arc;
use tonic::metadata::MetadataMap;
use tonic::service::Interceptor;
use tonic::{Request, Status};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Principal {
pub subject: String,
pub provider: String,
pub roles: BTreeSet<String>,
pub claims: BTreeMap<String, String>,
}
impl Principal {
#[must_use]
pub fn anonymous() -> Self {
Self {
subject: "anonymous".to_owned(),
provider: "anonymous".to_owned(),
roles: BTreeSet::new(),
claims: BTreeMap::new(),
}
}
#[must_use]
pub fn new(provider: impl Into<String>, subject: impl Into<String>) -> Self {
Self {
subject: subject.into(),
provider: provider.into(),
roles: BTreeSet::new(),
claims: BTreeMap::new(),
}
}
#[must_use]
pub fn with_role(mut self, role: impl Into<String>) -> Self {
self.roles.insert(role.into());
self
}
#[must_use]
pub fn with_claim(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.claims.insert(key.into(), value.into());
self
}
#[must_use]
pub fn is_anonymous(&self) -> bool {
self.provider == "anonymous"
}
#[must_use]
pub fn has_role(&self, role: &str) -> bool {
self.roles.contains(role)
}
#[must_use]
pub fn claim(&self, key: &str) -> Option<&str> {
self.claims.get(key).map(String::as_str)
}
}
#[derive(Clone, Debug, Default)]
pub struct Credentials {
pub bearer: Option<String>,
pub client_cert_subject: Option<String>,
}
impl Credentials {
#[must_use]
pub fn from_metadata(metadata: &MetadataMap) -> Self {
let bearer = metadata
.get("authorization")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.map(str::to_owned);
Self {
bearer,
client_cert_subject: None,
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.bearer.is_none() && self.client_cert_subject.is_none()
}
}
#[derive(Clone, Debug, thiserror::Error)]
pub enum AuthError {
#[error("unauthenticated: {0}")]
Unauthenticated(String),
#[error("forbidden: {0}")]
Forbidden(String),
}
impl AuthError {
#[must_use]
pub fn to_status(&self) -> Status {
match self {
Self::Unauthenticated(message) => Status::unauthenticated(message.clone()),
Self::Forbidden(message) => Status::permission_denied(message.clone()),
}
}
}
pub trait IdentityProvider: Send + Sync + 'static {
fn name(&self) -> &str;
fn authenticate(&self, credentials: &Credentials) -> Result<Option<Principal>, AuthError>;
}
#[derive(Clone, Debug, Default)]
pub struct AllowAnonymous;
impl IdentityProvider for AllowAnonymous {
#[allow(clippy::unnecessary_literal_bound)]
fn name(&self) -> &str {
"anonymous"
}
fn authenticate(&self, _credentials: &Credentials) -> Result<Option<Principal>, AuthError> {
Ok(Some(Principal::anonymous()))
}
}
#[derive(Clone, Debug, Default)]
pub struct StaticTokens {
tokens: BTreeMap<String, Principal>,
}
impl StaticTokens {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with(mut self, token: impl Into<String>, principal: Principal) -> Self {
self.tokens.insert(token.into(), principal);
self
}
}
impl IdentityProvider for StaticTokens {
#[allow(clippy::unnecessary_literal_bound)]
fn name(&self) -> &str {
"static-token"
}
fn authenticate(&self, credentials: &Credentials) -> Result<Option<Principal>, AuthError> {
Ok(credentials
.bearer
.as_ref()
.and_then(|token| self.tokens.get(token).cloned()))
}
}
pub trait TokenVerifier: Send + Sync + 'static {
fn verify(&self, token: &str) -> Result<Principal, AuthError>;
}
pub struct ExternalTokens<V: TokenVerifier> {
name: String,
verifier: V,
}
impl<V: TokenVerifier> ExternalTokens<V> {
pub fn new(name: impl Into<String>, verifier: V) -> Self {
Self {
name: name.into(),
verifier,
}
}
}
impl<V: TokenVerifier> IdentityProvider for ExternalTokens<V> {
fn name(&self) -> &str {
&self.name
}
fn authenticate(&self, credentials: &Credentials) -> Result<Option<Principal>, AuthError> {
match &credentials.bearer {
None => Ok(None),
Some(token) => self.verifier.verify(token).map(Some),
}
}
}
#[derive(Clone)]
pub struct CompositeProvider {
providers: Vec<Arc<dyn IdentityProvider>>,
}
impl CompositeProvider {
#[must_use]
pub fn new(providers: Vec<Arc<dyn IdentityProvider>>) -> Self {
Self { providers }
}
}
impl IdentityProvider for CompositeProvider {
#[allow(clippy::unnecessary_literal_bound)]
fn name(&self) -> &str {
"composite"
}
fn authenticate(&self, credentials: &Credentials) -> Result<Option<Principal>, AuthError> {
for provider in &self.providers {
if let Some(principal) = provider.authenticate(credentials)? {
return Ok(Some(principal));
}
}
Ok(None)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Action {
Query,
Pull,
Datoms,
TxRange,
Subscribe,
Inspect,
Transact,
CreateDatabase,
DeleteDatabase,
ForkDatabase,
ListDatabases,
GarbageCollect,
ManageIndex,
}
impl Action {
#[must_use]
pub fn is_write(self) -> bool {
matches!(self, Self::Transact)
}
#[must_use]
pub fn is_admin(self) -> bool {
matches!(
self,
Self::CreateDatabase
| Self::DeleteDatabase
| Self::ForkDatabase
| Self::GarbageCollect
| Self::ManageIndex
)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Access {
pub action: Action,
pub database: Option<String>,
}
impl Access {
#[must_use]
pub fn on(action: Action, database: impl Into<String>) -> Self {
Self {
action,
database: Some(database.into()),
}
}
#[must_use]
pub fn catalog(action: Action) -> Self {
Self {
action,
database: None,
}
}
}
pub trait ViewFilter: Send + Sync + fmt::Debug {
fn attribute_visible(&self, attribute: &str) -> bool;
}
#[derive(Clone, Debug)]
pub struct AttributeAllowlist {
allowed: BTreeSet<String>,
}
impl AttributeAllowlist {
pub fn new(attributes: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self {
allowed: attributes.into_iter().map(Into::into).collect(),
}
}
}
impl ViewFilter for AttributeAllowlist {
fn attribute_visible(&self, attribute: &str) -> bool {
self.allowed.contains(attribute)
}
}
#[derive(Clone)]
pub enum Decision {
Allow,
AllowFiltered(Arc<dyn ViewFilter>),
Deny(String),
}
impl fmt::Debug for Decision {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Allow => formatter.write_str("Allow"),
Self::AllowFiltered(filter) => write!(formatter, "AllowFiltered({filter:?})"),
Self::Deny(reason) => write!(formatter, "Deny({reason:?})"),
}
}
}
#[tonic::async_trait]
pub trait Authorizer: Send + Sync + 'static {
async fn authorize(&self, principal: &Principal, access: &Access) -> Decision;
}
#[derive(Clone, Debug, Default)]
pub struct AllowAll;
#[tonic::async_trait]
impl Authorizer for AllowAll {
async fn authorize(&self, _principal: &Principal, _access: &Access) -> Decision {
Decision::Allow
}
}
#[derive(Clone, Debug)]
pub struct Grant {
pub actions: BTreeSet<ActionClass>,
pub databases: BTreeSet<String>,
pub view: Option<Arc<dyn ViewFilter>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum ActionClass {
Read,
Write,
Admin,
}
impl ActionClass {
#[must_use]
pub fn of(action: Action) -> Self {
if action.is_admin() {
Self::Admin
} else if action.is_write() {
Self::Write
} else {
Self::Read
}
}
}
impl Grant {
#[must_use]
pub fn new(
actions: impl IntoIterator<Item = ActionClass>,
databases: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
Self {
actions: actions.into_iter().collect(),
databases: databases.into_iter().map(Into::into).collect(),
view: None,
}
}
#[must_use]
pub fn with_view(mut self, view: Arc<dyn ViewFilter>) -> Self {
self.view = Some(view);
self
}
fn covers(&self, access: &Access) -> bool {
let action_ok =
self.actions.is_empty() || self.actions.contains(&ActionClass::of(access.action));
let database_ok = self.databases.is_empty()
|| access
.database
.as_ref()
.is_some_and(|database| self.databases.contains(database));
action_ok && database_ok
}
}
#[derive(Clone, Default)]
pub struct PolicyAuthorizer {
roles: BTreeMap<String, Vec<Grant>>,
}
impl PolicyAuthorizer {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn grant(mut self, role: impl Into<String>, grant: Grant) -> Self {
self.roles.entry(role.into()).or_default().push(grant);
self
}
}
#[tonic::async_trait]
impl Authorizer for PolicyAuthorizer {
async fn authorize(&self, principal: &Principal, access: &Access) -> Decision {
let mut matched = false;
let mut view: Option<Arc<dyn ViewFilter>> = None;
for role in &principal.roles {
let Some(grants) = self.roles.get(role) else {
continue;
};
for grant in grants {
if grant.covers(access) {
matched = true;
match &grant.view {
Some(filter) if view.is_none() => view = Some(Arc::clone(filter)),
Some(_) => {}
None => return Decision::Allow,
}
}
}
}
if !matched {
return Decision::Deny(format!(
"principal {:?} has no grant for {:?}",
principal.subject, access
));
}
match view {
Some(filter) => Decision::AllowFiltered(filter),
None => Decision::Allow,
}
}
}
#[derive(Clone)]
pub struct Guard {
identity: Arc<dyn IdentityProvider>,
authorizer: Arc<dyn Authorizer>,
allow_anonymous: bool,
}
impl Guard {
#[must_use]
pub fn new(identity: Arc<dyn IdentityProvider>, authorizer: Arc<dyn Authorizer>) -> Self {
Self {
identity,
authorizer,
allow_anonymous: false,
}
}
#[must_use]
pub fn allow_anonymous(mut self, allow: bool) -> Self {
self.allow_anonymous = allow;
self
}
#[must_use]
pub fn disabled() -> Self {
Self::new(Arc::new(AllowAnonymous), Arc::new(AllowAll)).allow_anonymous(true)
}
#[must_use]
pub fn is_disabled(&self) -> bool {
self.identity.name() == "anonymous"
}
pub fn authenticate(&self, credentials: &Credentials) -> Result<Principal, AuthError> {
match self.identity.authenticate(credentials)? {
Some(principal) => Ok(principal),
None if self.allow_anonymous => Ok(Principal::anonymous()),
None if credentials.is_empty() => Err(AuthError::Unauthenticated(
"authentication is required".to_owned(),
)),
None => Err(AuthError::Unauthenticated(
"no provider accepted the presented credentials".to_owned(),
)),
}
}
pub async fn authorize(
&self,
principal: &Principal,
access: &Access,
) -> Result<Option<Arc<dyn ViewFilter>>, AuthError> {
match self.authorizer.authorize(principal, access).await {
Decision::Allow => Ok(None),
Decision::AllowFiltered(filter) => Ok(Some(filter)),
Decision::Deny(reason) => Err(AuthError::Forbidden(reason)),
}
}
}
#[derive(Clone)]
pub struct IdentityInterceptor {
guard: Guard,
}
impl IdentityInterceptor {
#[must_use]
pub fn new(guard: Guard) -> Self {
Self { guard }
}
}
impl Interceptor for IdentityInterceptor {
fn call(&mut self, mut request: Request<()>) -> Result<Request<()>, Status> {
let credentials = Credentials::from_metadata(request.metadata());
let principal = self
.guard
.authenticate(&credentials)
.map_err(|error| error.to_status())?;
request.extensions_mut().insert(principal);
Ok(request)
}
}
#[must_use]
pub fn principal<T>(request: &Request<T>) -> Principal {
request
.extensions()
.get::<Principal>()
.cloned()
.unwrap_or_else(Principal::anonymous)
}
#[cfg(test)]
mod tests {
use super::*;
fn creds(bearer: &str) -> Credentials {
Credentials {
bearer: Some(bearer.to_owned()),
client_cert_subject: None,
}
}
#[tokio::test]
async fn disabled_guard_allows_anonymous_everything() {
let guard = Guard::disabled();
assert!(guard.is_disabled());
let principal = guard.authenticate(&Credentials::default()).unwrap();
assert!(principal.is_anonymous());
assert!(
guard
.authorize(&principal, &Access::on(Action::Transact, "people"))
.await
.unwrap()
.is_none()
);
}
#[test]
fn static_tokens_map_to_distinct_principals() {
let provider = StaticTokens::new()
.with(
"reader-secret",
Principal::new("static-token", "reader").with_role("reader"),
)
.with(
"writer-secret",
Principal::new("static-token", "writer").with_role("writer"),
);
let reader = provider.authenticate(&creds("reader-secret")).unwrap();
assert_eq!(reader.unwrap().subject, "reader");
let writer = provider.authenticate(&creds("writer-secret")).unwrap();
assert!(writer.unwrap().has_role("writer"));
assert!(
provider
.authenticate(&Credentials::default())
.unwrap()
.is_none()
);
assert!(provider.authenticate(&creds("bogus")).unwrap().is_none());
}
struct FakeJwt;
impl TokenVerifier for FakeJwt {
fn verify(&self, token: &str) -> Result<Principal, AuthError> {
let mut parts = token.split(':');
match (parts.next(), parts.next(), parts.next()) {
(Some("oidc"), Some(sub), Some(tenant)) => Ok(Principal::new("oidc", sub)
.with_role("reader")
.with_claim("tenant", tenant)),
_ => Err(AuthError::Unauthenticated("bad token".to_owned())),
}
}
}
#[test]
fn external_token_verifier_seam_produces_principal() {
let provider = ExternalTokens::new("oidc", FakeJwt);
let principal = provider
.authenticate(&creds("oidc:alice:acme"))
.unwrap()
.unwrap();
assert_eq!(principal.provider, "oidc");
assert_eq!(principal.claim("tenant"), Some("acme"));
assert!(matches!(
provider.authenticate(&creds("garbage")),
Err(AuthError::Unauthenticated(_))
));
}
#[test]
fn composite_provider_tries_in_order_and_abstains() {
let statics = StaticTokens::new().with(
"svc-secret",
Principal::new("static-token", "svc").with_role("writer"),
);
let provider = CompositeProvider::new(vec![
Arc::new(statics),
Arc::new(ExternalTokens::new("oidc", FakeJwt)),
]);
assert_eq!(
provider
.authenticate(&creds("svc-secret"))
.unwrap()
.unwrap()
.subject,
"svc"
);
assert_eq!(
provider
.authenticate(&creds("oidc:bob:beta"))
.unwrap()
.unwrap()
.provider,
"oidc"
);
assert!(
provider
.authenticate(&Credentials::default())
.unwrap()
.is_none()
);
let strict = Guard::new(Arc::new(provider.clone()), Arc::new(AllowAll));
assert!(matches!(
strict.authenticate(&Credentials::default()),
Err(AuthError::Unauthenticated(_))
));
let open = Guard::new(Arc::new(provider), Arc::new(AllowAll)).allow_anonymous(true);
assert!(
open.authenticate(&Credentials::default())
.unwrap()
.is_anonymous()
);
}
fn sample_policy() -> PolicyAuthorizer {
PolicyAuthorizer::new()
.grant("reader", Grant::new([ActionClass::Read], ["people"]))
.grant(
"writer",
Grant::new([ActionClass::Read, ActionClass::Write], ["people"]),
)
.grant(
"admin",
Grant::new([ActionClass::Admin], Vec::<String>::new()),
)
}
#[tokio::test]
async fn policy_authorizer_enforces_actions_and_databases() {
let policy = sample_policy();
let reader = Principal::new("t", "r").with_role("reader");
let writer = Principal::new("t", "w").with_role("writer");
let admin = Principal::new("t", "a").with_role("admin");
assert!(matches!(
policy
.authorize(&reader, &Access::on(Action::Query, "people"))
.await,
Decision::Allow
));
assert!(matches!(
policy
.authorize(&reader, &Access::on(Action::Transact, "people"))
.await,
Decision::Deny(_)
));
assert!(matches!(
policy
.authorize(&reader, &Access::on(Action::Query, "secrets"))
.await,
Decision::Deny(_)
));
assert!(matches!(
policy
.authorize(&writer, &Access::on(Action::Transact, "people"))
.await,
Decision::Allow
));
assert!(matches!(
policy
.authorize(&admin, &Access::catalog(Action::CreateDatabase))
.await,
Decision::Allow
));
assert!(matches!(
policy
.authorize(&admin, &Access::on(Action::Query, "people"))
.await,
Decision::Deny(_)
));
}
#[tokio::test]
async fn per_principal_view_filter_gives_different_views() {
let acme_view: Arc<dyn ViewFilter> = Arc::new(AttributeAllowlist::new([
":person/name",
":person/acme-note",
]));
let beta_view: Arc<dyn ViewFilter> = Arc::new(AttributeAllowlist::new([
":person/name",
":person/beta-note",
]));
let policy = PolicyAuthorizer::new()
.grant(
"acme",
Grant::new([ActionClass::Read], ["people"]).with_view(acme_view),
)
.grant(
"beta",
Grant::new([ActionClass::Read], ["people"]).with_view(beta_view),
);
let guard = Guard::new(Arc::new(AllowAnonymous), Arc::new(policy));
let acme = Principal::new("oidc", "alice").with_role("acme");
let beta = Principal::new("oidc", "bob").with_role("beta");
let acme_filter = guard
.authorize(&acme, &Access::on(Action::Query, "people"))
.await
.unwrap()
.expect("acme is filtered");
let beta_filter = guard
.authorize(&beta, &Access::on(Action::Query, "people"))
.await
.unwrap()
.expect("beta is filtered");
assert!(acme_filter.attribute_visible(":person/name"));
assert!(acme_filter.attribute_visible(":person/acme-note"));
assert!(!acme_filter.attribute_visible(":person/beta-note"));
assert!(beta_filter.attribute_visible(":person/beta-note"));
assert!(!beta_filter.attribute_visible(":person/acme-note"));
}
#[test]
fn interceptor_attaches_principal_to_extensions() {
let guard = Guard::new(
Arc::new(StaticTokens::new().with(
"reader-secret",
Principal::new("static-token", "reader").with_role("reader"),
)),
Arc::new(AllowAll),
);
let mut interceptor = IdentityInterceptor::new(guard);
let mut request = Request::new(());
request
.metadata_mut()
.insert("authorization", "Bearer reader-secret".parse().unwrap());
let request = interceptor.call(request).unwrap();
assert_eq!(principal(&request).subject, "reader");
let mut bad = Request::new(());
bad.metadata_mut()
.insert("authorization", "Bearer nope".parse().unwrap());
assert_eq!(
interceptor.call(bad).unwrap_err().code(),
tonic::Code::Unauthenticated
);
}
#[tokio::test]
async fn one_guard_serves_two_tenants_with_different_authority() {
let provider = CompositeProvider::new(vec![Arc::new(
StaticTokens::new()
.with(
"acme-writer",
Principal::new("static-token", "acme-svc").with_role("acme"),
)
.with(
"beta-reader",
Principal::new("static-token", "beta-user").with_role("beta"),
),
)]);
let policy = PolicyAuthorizer::new()
.grant(
"acme",
Grant::new([ActionClass::Read, ActionClass::Write], ["acme-db"]),
)
.grant("beta", Grant::new([ActionClass::Read], ["beta-db"]));
let guard = Guard::new(Arc::new(provider), Arc::new(policy));
let acme = guard.authenticate(&creds("acme-writer")).unwrap();
let beta = guard.authenticate(&creds("beta-reader")).unwrap();
assert!(
guard
.authorize(&acme, &Access::on(Action::Transact, "acme-db"))
.await
.is_ok()
);
assert!(
guard
.authorize(&beta, &Access::on(Action::Query, "acme-db"))
.await
.is_err()
);
assert!(
guard
.authorize(&beta, &Access::on(Action::Query, "beta-db"))
.await
.is_ok()
);
assert!(
guard
.authorize(&beta, &Access::on(Action::Transact, "beta-db"))
.await
.is_err()
);
}
struct FakeOracle {
allowed: BTreeSet<(String, Action)>,
}
#[tonic::async_trait]
impl Authorizer for FakeOracle {
async fn authorize(&self, principal: &Principal, access: &Access) -> Decision {
tokio::task::yield_now().await;
let key = (principal.subject.clone(), access.action);
if self.allowed.contains(&key) {
Decision::Allow
} else {
Decision::Deny("oracle check failed".to_owned())
}
}
}
#[tokio::test]
async fn external_async_oracle_authorizer() {
let oracle = FakeOracle {
allowed: [("alice".to_owned(), Action::Query)].into_iter().collect(),
};
let guard = Guard::new(Arc::new(AllowAnonymous), Arc::new(oracle));
let alice = Principal::new("oidc", "alice");
let bob = Principal::new("oidc", "bob");
assert!(
guard
.authorize(&alice, &Access::on(Action::Query, "people"))
.await
.is_ok()
);
assert!(
guard
.authorize(&alice, &Access::on(Action::Transact, "people"))
.await
.is_err()
);
assert!(
guard
.authorize(&bob, &Access::on(Action::Query, "people"))
.await
.is_err()
);
}
}