use crate::error::SkiljRejection;
use crate::event_store::BoundedContext;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum RoleStatus {
Active,
Revoked,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Role {
pub id: String,
pub external_subject: String,
pub name: String,
pub superadmin: bool,
pub status: RoleStatus,
pub created_at: chrono::DateTime<chrono::Utc>,
pub revoked_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum AccessLevel {
Read,
Write,
Admin,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RoleAccessMapping {
pub role: Role,
pub bounded_context: BoundedContext,
pub level: AccessLevel,
pub can_read_sensitive: bool,
pub scope: Option<String>,
pub capabilities: std::collections::BTreeSet<String>,
pub delegable: std::collections::BTreeSet<String>,
pub status: RoleStatus,
pub created_at: chrono::DateTime<chrono::Utc>,
pub revoked_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrivateFieldGrant {
pub id: String,
pub bounded_context: BoundedContext,
pub grantor: Role,
pub grantee: Role,
pub event_sequence: Option<i64>,
pub command_id: Option<String>,
pub status: TokenStatus,
pub created_at: chrono::DateTime<chrono::Utc>,
pub revoked_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum TokenStatus {
Active,
Revoked,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EventReadToken {
pub id: String,
pub secret: String,
pub status: TokenStatus,
pub created_at: chrono::DateTime<chrono::Utc>,
pub revoked_at: Option<chrono::DateTime<chrono::Utc>>,
pub event_type: crate::event_store::EventType,
pub scope: Option<String>,
pub start_from: EventReadStartPosition,
pub start_at_sequence: Option<i64>,
pub start_at_time: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum EventReadStartPosition {
Beginning,
Latest,
AtSequence,
AtTime,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExternalEventToken {
pub id: String,
pub secret: String,
pub status: TokenStatus,
pub created_at: chrono::DateTime<chrono::Utc>,
pub revoked_at: Option<chrono::DateTime<chrono::Utc>>,
pub event_type: crate::event_store::EventType,
pub scope: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirectCreationToken {
pub id: String,
pub secret: String,
pub status: TokenStatus,
pub created_at: chrono::DateTime<chrono::Utc>,
pub revoked_at: Option<chrono::DateTime<chrono::Utc>>,
pub event_type: crate::event_store::EventType,
pub scope: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandToken {
pub id: String,
pub secret: String,
pub status: TokenStatus,
pub created_at: chrono::DateTime<chrono::Utc>,
pub revoked_at: Option<chrono::DateTime<chrono::Utc>>,
pub command_type: crate::event_store::CommandType,
pub scope: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum AccessToken {
ExternalEventToken(ExternalEventToken),
DirectCreationToken(DirectCreationToken),
CommandToken(CommandToken),
EventReadToken(EventReadToken),
}
impl AccessToken {
fn status(&self) -> TokenStatus {
match self {
AccessToken::ExternalEventToken(t) => t.status,
AccessToken::DirectCreationToken(t) => t.status,
AccessToken::CommandToken(t) => t.status,
AccessToken::EventReadToken(t) => t.status,
}
}
fn scope(&self) -> &BoundedContext {
match self {
AccessToken::ExternalEventToken(t) => &t.event_type.bounded_context,
AccessToken::DirectCreationToken(t) => &t.event_type.bounded_context,
AccessToken::CommandToken(t) => &t.command_type.bounded_context,
AccessToken::EventReadToken(t) => &t.event_type.bounded_context,
}
}
fn owner_scope(&self) -> Option<&str> {
match self {
AccessToken::ExternalEventToken(t) => t.scope.as_deref(),
AccessToken::DirectCreationToken(t) => t.scope.as_deref(),
AccessToken::CommandToken(t) => t.scope.as_deref(),
AccessToken::EventReadToken(t) => t.scope.as_deref(),
}
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("this Role is not active")]
RoleNotActive,
#[error("this RoleAccessMapping is not active")]
GrantNotActive,
#[error("this grant's access level is too low for this action")]
InsufficientAccessLevel,
#[error("this RoleAccessMapping is scoped to a different bounded context")]
GrantBoundedContextMismatch,
#[error("this grant is scoped to a value that does not match this record's own owner")]
GrantScopeMismatch,
#[error("this grant does not hold the capability this projection requires to query it")]
CapabilityNotHeld,
#[error("a capability name is lowercase letters, digits, '_' and '-', 1 to 64 characters")]
InvalidCapabilityName,
#[error("this grant already holds this capability, or holds the most capabilities allowed")]
CapabilityNotAdded,
#[error("this grant does not hold this capability")]
CapabilityNotRevoked,
#[error("this capability is not one this admin grant may delegate here")]
CapabilityNotDelegable,
#[error("an admin grant cannot change its own capabilities")]
SelfGrantRefused,
#[error("the caller is not this record's own default private reader")]
NotDefaultPrivateReader,
#[error("only the Role that made this PrivateFieldGrant may revoke it")]
NotGrantor,
#[error("this PrivateFieldGrant is not active")]
PrivateFieldGrantNotActive,
#[error("this record does not belong to the grantor's own bounded context")]
PrivateFieldRecordWrongBoundedContext,
#[error("this AccessToken is not active")]
TokenNotActive,
#[error("only an active superadmin Role may perform this action")]
NotSuperadmin,
#[error("no active Role's external_subject matches this JWT's subject claim")]
UnrecognisedSubject,
#[error("this external_subject is already claimed by another active Role")]
ExternalSubjectAlreadyClaimed,
#[error("this Role already holds an active RoleAccessMapping for this bounded context")]
DuplicateActiveMapping,
#[error("the presented JWT is malformed: {0}")]
MalformedJwt(String),
#[error("the presented JWT has no key id (kid) header, so no JWKS entry can verify it")]
JwtMissingKeyId,
#[error(
"the presented JWT's key id (kid) doesn't match any key in the trusted IdP's JWKS, \
even after a refetch"
)]
UnknownSigningKey,
#[error("the presented JWT failed verification: {0}")]
JwtVerificationFailed(String),
#[error("the presented JWT's verified claims have no {0} claim to trust as the subject")]
MissingSubjectClaim(String),
#[error("fetching the trusted IdP's JWKS failed: {0}")]
JwksFetchFailed(String),
#[error("start_from = at_sequence requires start_at_sequence and forbids start_at_time")]
StartAtSequenceMismatch,
#[error("start_from = at_time requires start_at_time and forbids start_at_sequence")]
StartAtTimeMismatch,
#[error("start_from = beginning or latest forbids both start_at_sequence and start_at_time")]
StartAtValueNotAllowed,
#[error(
"this admin grant is scoped, so a token it mints or revokes must have the same \
scope (a minted one may omit it to inherit that scope)"
)]
TokenScopeBeyondGrant,
}
impl SkiljRejection for Error {
fn code(&self) -> &str {
match self {
Error::RoleNotActive => "role_not_active",
Error::GrantNotActive => "grant_not_active",
Error::InsufficientAccessLevel => "insufficient_access_level",
Error::GrantBoundedContextMismatch => "grant_bounded_context_mismatch",
Error::GrantScopeMismatch => "grant_scope_mismatch",
Error::TokenScopeBeyondGrant => "token_scope_beyond_grant",
Error::CapabilityNotHeld => "capability_not_held",
Error::InvalidCapabilityName => "invalid_capability_name",
Error::CapabilityNotAdded => "capability_not_added",
Error::CapabilityNotRevoked => "capability_not_revoked",
Error::CapabilityNotDelegable => "capability_not_delegable",
Error::SelfGrantRefused => "self_grant_refused",
Error::NotDefaultPrivateReader => "not_default_private_reader",
Error::NotGrantor => "not_grantor",
Error::PrivateFieldGrantNotActive => "private_field_grant_not_active",
Error::PrivateFieldRecordWrongBoundedContext => {
"private_field_record_wrong_bounded_context"
}
Error::TokenNotActive => "token_not_active",
Error::NotSuperadmin => "not_superadmin",
Error::UnrecognisedSubject => "unrecognised_subject",
Error::ExternalSubjectAlreadyClaimed => "external_subject_already_claimed",
Error::DuplicateActiveMapping => "duplicate_active_mapping",
Error::MalformedJwt(_) => "malformed_jwt",
Error::JwtMissingKeyId => "jwt_missing_key_id",
Error::UnknownSigningKey => "unknown_signing_key",
Error::JwtVerificationFailed(_) => "jwt_verification_failed",
Error::MissingSubjectClaim(_) => "missing_subject_claim",
Error::JwksFetchFailed(_) => "jwks_fetch_failed",
Error::StartAtSequenceMismatch => "start_at_sequence_mismatch",
Error::StartAtTimeMismatch => "start_at_time_mismatch",
Error::StartAtValueNotAllowed => "start_at_value_not_allowed",
}
}
fn message(&self) -> String {
self.to_string()
}
}
pub fn scope_matches_owner(owner: Option<&str>, scope: Option<&str>) -> bool {
match scope {
None => true,
Some(scope) => owner == Some(scope),
}
}
pub const MAX_CAPABILITIES: usize = 16;
pub fn valid_capability_name(name: &str) -> bool {
(1..=64).contains(&name.len())
&& name
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
}
pub fn mapping_holds_capability(mapping: &RoleAccessMapping, required: Option<&str>) -> bool {
match required {
None => true,
Some(name) => mapping.capabilities.contains(name),
}
}
#[derive(Debug, Clone, Copy)]
pub enum CapabilityActor<'a> {
Superadmin(&'a Role),
Admin(&'a RoleAccessMapping),
}
fn check_capability_actor(
actor: CapabilityActor<'_>,
target: &RoleAccessMapping,
capability: &str,
) -> crate::error::Result<()> {
if target.status != RoleStatus::Active {
return Err(Error::GrantNotActive.into());
}
match actor {
CapabilityActor::Superadmin(caller) => require_active_superadmin(caller),
CapabilityActor::Admin(admin) => {
if admin.status != RoleStatus::Active {
return Err(Error::GrantNotActive.into());
}
if admin.level != AccessLevel::Admin {
return Err(Error::InsufficientAccessLevel.into());
}
if admin.bounded_context != target.bounded_context {
return Err(Error::GrantBoundedContextMismatch.into());
}
if admin.role == target.role {
return Err(Error::SelfGrantRefused.into());
}
if !admin.delegable.contains(capability) {
return Err(Error::CapabilityNotDelegable.into());
}
Ok(())
}
}
}
pub fn grant_capability(
actor: CapabilityActor<'_>,
target: &RoleAccessMapping,
capability: &str,
) -> crate::error::Result<RoleAccessMapping> {
check_capability_actor(actor, target, capability)?;
if !valid_capability_name(capability) {
return Err(Error::InvalidCapabilityName.into());
}
if target.capabilities.contains(capability) || target.capabilities.len() >= MAX_CAPABILITIES {
return Err(Error::CapabilityNotAdded.into());
}
let mut updated = target.clone();
updated.capabilities.insert(capability.to_owned());
Ok(updated)
}
pub fn revoke_capability(
actor: CapabilityActor<'_>,
target: &RoleAccessMapping,
capability: &str,
) -> crate::error::Result<RoleAccessMapping> {
check_capability_actor(actor, target, capability)?;
if !target.capabilities.contains(capability) {
return Err(Error::CapabilityNotRevoked.into());
}
let mut updated = target.clone();
updated.capabilities.remove(capability);
Ok(updated)
}
pub fn set_delegable_capabilities(
caller: &Role,
target: &RoleAccessMapping,
delegable: std::collections::BTreeSet<String>,
) -> crate::error::Result<RoleAccessMapping> {
require_active_superadmin(caller)?;
if target.status != RoleStatus::Active {
return Err(Error::GrantNotActive.into());
}
if target.level != AccessLevel::Admin {
return Err(Error::InsufficientAccessLevel.into());
}
if delegable.len() > MAX_CAPABILITIES || !delegable.iter().all(|c| valid_capability_name(c)) {
return Err(Error::InvalidCapabilityName.into());
}
let mut updated = target.clone();
updated.delegable = delegable;
Ok(updated)
}
pub fn require_active_superadmin(caller: &Role) -> crate::error::Result<()> {
if caller.status != RoleStatus::Active {
return Err(Error::RoleNotActive.into());
}
if !caller.superadmin {
return Err(Error::NotSuperadmin.into());
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct IdpConfig {
pub jwks_endpoint: reqwest::Url,
pub issuer: String,
pub signing_algorithm: SigningAlgorithm,
pub audiences: Vec<String>,
pub subject_claim: String,
}
impl IdpConfig {
pub fn new(
jwks_endpoint: reqwest::Url,
issuer: impl Into<String>,
audience: impl Into<String>,
signing_algorithm: SigningAlgorithm,
) -> Self {
Self {
jwks_endpoint,
issuer: issuer.into(),
signing_algorithm,
audiences: vec![audience.into()],
subject_claim: "sub".to_string(),
}
}
pub fn with_additional_audience(mut self, audience: impl Into<String>) -> Self {
self.audiences.push(audience.into());
self
}
pub fn with_subject_claim(mut self, claim: impl Into<String>) -> Self {
self.subject_claim = claim.into();
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SigningAlgorithm {
Rs256,
Rs384,
Rs512,
Es256,
Es384,
}
impl SigningAlgorithm {
fn to_jsonwebtoken(self) -> jsonwebtoken::Algorithm {
match self {
SigningAlgorithm::Rs256 => jsonwebtoken::Algorithm::RS256,
SigningAlgorithm::Rs384 => jsonwebtoken::Algorithm::RS384,
SigningAlgorithm::Rs512 => jsonwebtoken::Algorithm::RS512,
SigningAlgorithm::Es256 => jsonwebtoken::Algorithm::ES256,
SigningAlgorithm::Es384 => jsonwebtoken::Algorithm::ES384,
}
}
}
pub struct JwksCache {
client: reqwest::Client,
fetch_timeout: std::time::Duration,
jwks_endpoint: reqwest::Url,
min_refetch_interval: std::time::Duration,
keys: tokio::sync::RwLock<std::collections::HashMap<String, jsonwebtoken::DecodingKey>>,
last_refetch: tokio::sync::Mutex<Option<std::time::Instant>>,
}
impl JwksCache {
pub const DEFAULT_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
pub fn with_fetch_timeout(mut self, timeout: std::time::Duration) -> Self {
self.fetch_timeout = timeout;
self
}
pub fn new(jwks_endpoint: reqwest::Url) -> Self {
Self {
client: reqwest::Client::new(),
fetch_timeout: Self::DEFAULT_FETCH_TIMEOUT,
jwks_endpoint,
min_refetch_interval: std::time::Duration::from_secs(5),
keys: tokio::sync::RwLock::new(std::collections::HashMap::new()),
last_refetch: tokio::sync::Mutex::new(None),
}
}
async fn key_for(&self, kid: &str) -> crate::error::Result<Option<jsonwebtoken::DecodingKey>> {
if let Some(key) = self.keys.read().await.get(kid) {
return Ok(Some(key.clone()));
}
let mut last_refetch = self.last_refetch.lock().await;
if let Some(key) = self.keys.read().await.get(kid) {
return Ok(Some(key.clone()));
}
let now = std::time::Instant::now();
if last_refetch.is_some_and(|last| now.duration_since(last) < self.min_refetch_interval) {
return Ok(None);
}
*last_refetch = Some(now);
self.refetch().await?;
Ok(self.keys.read().await.get(kid).cloned())
}
#[tracing::instrument(skip_all, fields(jwks_endpoint = %self.jwks_endpoint))]
async fn refetch(&self) -> crate::error::Result<()> {
let jwks: jsonwebtoken::jwk::JwkSet = self
.client
.get(self.jwks_endpoint.clone())
.timeout(self.fetch_timeout)
.send()
.await
.map_err(|e| Error::JwksFetchFailed(e.to_string()))?
.json()
.await
.map_err(|e| Error::JwksFetchFailed(e.to_string()))?;
let mut keys = self.keys.write().await;
keys.clear();
for jwk in &jwks.keys {
let (Some(kid), Ok(decoding_key)) = (
jwk.common.key_id.clone(),
jsonwebtoken::DecodingKey::from_jwk(jwk),
) else {
continue;
};
keys.insert(kid, decoding_key);
}
Ok(())
}
}
pub async fn verify_and_extract_subject(
jwt: &str,
config: &IdpConfig,
cache: &JwksCache,
) -> crate::error::Result<String> {
verify_jwt(jwt, config, cache).await.map(|v| v.subject)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedJwt {
pub subject: String,
pub valid_until: std::time::SystemTime,
}
pub async fn verify_jwt(
jwt: &str,
config: &IdpConfig,
cache: &JwksCache,
) -> crate::error::Result<VerifiedJwt> {
let header =
jsonwebtoken::decode_header(jwt).map_err(|e| Error::MalformedJwt(e.to_string()))?;
let kid = header.kid.ok_or(Error::JwtMissingKeyId)?;
let Some(decoding_key) = cache.key_for(&kid).await? else {
return Err(Error::UnknownSigningKey.into());
};
let mut validation = jsonwebtoken::Validation::new(config.signing_algorithm.to_jsonwebtoken());
validation.set_issuer(&[&config.issuer]);
validation.set_audience(&config.audiences);
validation.set_required_spec_claims(&["exp", "iss", "aud"]);
validation.validate_nbf = true;
let token_data = jsonwebtoken::decode::<serde_json::Map<String, serde_json::Value>>(
jwt,
&decoding_key,
&validation,
)
.map_err(|e| Error::JwtVerificationFailed(e.to_string()))?;
let subject = token_data
.claims
.get(&config.subject_claim)
.and_then(|v| v.as_str())
.map(str::to_string)
.ok_or_else(|| Error::MissingSubjectClaim(config.subject_claim.clone()))?;
let exp = token_data
.claims
.get("exp")
.and_then(|v| v.as_f64())
.ok_or_else(|| Error::JwtVerificationFailed("exp is not a number".to_string()))?;
let valid_until = std::time::UNIX_EPOCH
+ std::time::Duration::from_secs(exp.clamp(0.0, u32::MAX as f64) as u64)
+ std::time::Duration::from_secs(validation.leeway);
Ok(VerifiedJwt {
subject,
valid_until,
})
}
pub fn resolve_role_by_external_subject<'a>(
verified_subject: &str,
existing_roles: &'a [Role],
) -> crate::error::Result<&'a Role> {
existing_roles
.iter()
.find(|r| r.status == RoleStatus::Active && r.external_subject == verified_subject)
.ok_or_else(|| Error::UnrecognisedSubject.into())
}
pub fn create_role(
caller: &Role,
name: String,
superadmin: bool,
external_subject: String,
existing_roles: &[Role],
id: String,
now: chrono::DateTime<chrono::Utc>,
) -> crate::error::Result<Role> {
require_active_superadmin(caller)?;
if existing_roles
.iter()
.any(|r| r.external_subject == external_subject && r.status == RoleStatus::Active)
{
return Err(Error::ExternalSubjectAlreadyClaimed.into());
}
Ok(Role {
id,
external_subject,
name,
superadmin,
status: RoleStatus::Active,
created_at: now,
revoked_at: None,
})
}
pub fn revoke_role(
caller: &Role,
role: &Role,
active_mappings: &[RoleAccessMapping],
now: chrono::DateTime<chrono::Utc>,
) -> crate::error::Result<(Role, Vec<RoleAccessMapping>)> {
require_active_superadmin(caller)?;
if role.status != RoleStatus::Active {
return Err(Error::RoleNotActive.into());
}
let revoked_role = Role {
status: RoleStatus::Revoked,
revoked_at: Some(now),
..role.clone()
};
let revoked_mappings = active_mappings
.iter()
.cloned()
.map(|m| RoleAccessMapping {
status: RoleStatus::Revoked,
revoked_at: Some(now),
..m
})
.collect();
Ok((revoked_role, revoked_mappings))
}
#[allow(clippy::too_many_arguments)]
pub fn grant_role_access_mapping(
caller: &Role,
role: &Role,
bounded_context: &BoundedContext,
level: AccessLevel,
can_read_sensitive: bool,
scope: Option<String>,
existing_mappings: &[RoleAccessMapping],
now: chrono::DateTime<chrono::Utc>,
) -> crate::error::Result<RoleAccessMapping> {
require_active_superadmin(caller)?;
if role.status != RoleStatus::Active {
return Err(Error::RoleNotActive.into());
}
if bounded_context.status != crate::event_store::BoundedContextStatus::Active {
return Err(crate::event_store::Error::BoundedContextArchived.into());
}
if existing_mappings.iter().any(|m| {
&m.role == role && &m.bounded_context == bounded_context && m.status == RoleStatus::Active
}) {
return Err(Error::DuplicateActiveMapping.into());
}
Ok(RoleAccessMapping {
role: role.clone(),
bounded_context: bounded_context.clone(),
level,
can_read_sensitive,
scope,
capabilities: Default::default(),
delegable: Default::default(),
status: RoleStatus::Active,
created_at: now,
revoked_at: None,
})
}
pub fn revoke_role_access_mapping(
caller: &Role,
access_mapping: &RoleAccessMapping,
now: chrono::DateTime<chrono::Utc>,
) -> crate::error::Result<RoleAccessMapping> {
require_active_superadmin(caller)?;
if access_mapping.status != RoleStatus::Active {
return Err(Error::GrantNotActive.into());
}
Ok(RoleAccessMapping {
status: RoleStatus::Revoked,
revoked_at: Some(now),
..access_mapping.clone()
})
}
pub fn grant_private_field_access_for_event(
access_mapping: &RoleAccessMapping,
grantee: &Role,
event: Option<&crate::event_store::Event>,
id: String,
now: chrono::DateTime<chrono::Utc>,
) -> crate::error::Result<PrivateFieldGrant> {
if access_mapping.status != RoleStatus::Active {
return Err(Error::GrantNotActive.into());
}
if grantee.status != RoleStatus::Active {
return Err(Error::RoleNotActive.into());
}
if let Some(event) = event {
if !event
.bounded_context
.same_as(&access_mapping.bounded_context)
{
return Err(Error::PrivateFieldRecordWrongBoundedContext.into());
}
if !crate::event_store::is_default_private_reader(event, &access_mapping.role) {
return Err(Error::NotDefaultPrivateReader.into());
}
}
Ok(PrivateFieldGrant {
id,
bounded_context: access_mapping.bounded_context.clone(),
grantor: access_mapping.role.clone(),
grantee: grantee.clone(),
event_sequence: event.map(|e| e.sequence),
command_id: None,
status: TokenStatus::Active,
created_at: now,
revoked_at: None,
})
}
pub fn grant_private_field_access_for_command(
access_mapping: &RoleAccessMapping,
grantee: &Role,
command: Option<&crate::event_store::Command>,
id: String,
now: chrono::DateTime<chrono::Utc>,
) -> crate::error::Result<PrivateFieldGrant> {
if access_mapping.status != RoleStatus::Active {
return Err(Error::GrantNotActive.into());
}
if grantee.status != RoleStatus::Active {
return Err(Error::RoleNotActive.into());
}
if let Some(command) = command {
if !command
.bounded_context
.same_as(&access_mapping.bounded_context)
{
return Err(Error::PrivateFieldRecordWrongBoundedContext.into());
}
if !crate::event_store::is_default_private_reader(command, &access_mapping.role) {
return Err(Error::NotDefaultPrivateReader.into());
}
}
Ok(PrivateFieldGrant {
id,
bounded_context: access_mapping.bounded_context.clone(),
grantor: access_mapping.role.clone(),
grantee: grantee.clone(),
event_sequence: None,
command_id: command.map(|c| c.id.clone()),
status: TokenStatus::Active,
created_at: now,
revoked_at: None,
})
}
pub fn revoke_private_field_access(
access_mapping: &RoleAccessMapping,
grant: &PrivateFieldGrant,
now: chrono::DateTime<chrono::Utc>,
) -> crate::error::Result<PrivateFieldGrant> {
if access_mapping.status != RoleStatus::Active {
return Err(Error::GrantNotActive.into());
}
if access_mapping.role != grant.grantor {
return Err(Error::NotGrantor.into());
}
if grant.status != TokenStatus::Active {
return Err(Error::PrivateFieldGrantNotActive.into());
}
Ok(PrivateFieldGrant {
status: TokenStatus::Revoked,
revoked_at: Some(now),
..grant.clone()
})
}
pub fn list_private_field_grants(
access_mapping: &RoleAccessMapping,
grantor: Option<&Role>,
all_grants_in_context: &[PrivateFieldGrant],
) -> crate::error::Result<Vec<PrivateFieldGrant>> {
if access_mapping.status != RoleStatus::Active {
return Err(Error::GrantNotActive.into());
}
let subject = match grantor {
None => &access_mapping.role,
Some(named) if named == &access_mapping.role => named,
Some(named) if access_mapping.level == AccessLevel::Admin => named,
Some(_) => return Err(Error::InsufficientAccessLevel.into()),
};
Ok(all_grants_in_context
.iter()
.filter(|g| &g.grantor == subject)
.cloned()
.collect())
}
#[derive(Debug, Clone)]
pub struct RevokedMapping {
pub role_id: String,
pub bounded_context: String,
}
#[derive(Clone)]
pub struct RevocationBroadcaster {
sender: tokio::sync::broadcast::Sender<RevokedMapping>,
instance_id: String,
}
impl RevocationBroadcaster {
pub fn new() -> Self {
let (sender, _receiver) = tokio::sync::broadcast::channel(256);
Self {
sender,
instance_id: crate::shared::generate_token_id(),
}
}
pub fn instance_id(&self) -> &str {
&self.instance_id
}
pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<RevokedMapping> {
self.sender.subscribe()
}
pub fn publish(&self, revoked: RevokedMapping) {
let _ = self.sender.send(revoked);
}
}
impl Default for RevocationBroadcaster {
fn default() -> Self {
Self::new()
}
}
fn require_active_admin(access_mapping: &RoleAccessMapping) -> crate::error::Result<()> {
if access_mapping.status != RoleStatus::Active {
return Err(Error::GrantNotActive.into());
}
if access_mapping.level != AccessLevel::Admin {
return Err(Error::InsufficientAccessLevel.into());
}
Ok(())
}
fn scope_for_minted_token(
access_mapping: &RoleAccessMapping,
requested: Option<String>,
) -> crate::error::Result<Option<String>> {
match (access_mapping.scope.as_deref(), requested) {
(None, requested) => Ok(requested),
(Some(own), None) => Ok(Some(own.to_string())),
(Some(own), Some(requested)) if requested == own => Ok(Some(requested)),
(Some(_), Some(_)) => Err(Error::TokenScopeBeyondGrant.into()),
}
}
pub fn create_external_event_token(
access_mapping: &RoleAccessMapping,
event_type: &crate::event_store::EventType,
id: String,
secret: String,
scope: Option<String>,
now: chrono::DateTime<chrono::Utc>,
) -> crate::error::Result<ExternalEventToken> {
require_active_admin(access_mapping)?;
let scope = scope_for_minted_token(access_mapping, scope)?;
if !access_mapping
.bounded_context
.same_as(&event_type.bounded_context)
{
return Err(Error::GrantBoundedContextMismatch.into());
}
Ok(ExternalEventToken {
id,
secret,
status: TokenStatus::Active,
created_at: now,
revoked_at: None,
event_type: event_type.clone(),
scope,
})
}
pub fn create_direct_creation_token(
access_mapping: &RoleAccessMapping,
event_type: &crate::event_store::EventType,
id: String,
secret: String,
scope: Option<String>,
now: chrono::DateTime<chrono::Utc>,
) -> crate::error::Result<DirectCreationToken> {
require_active_admin(access_mapping)?;
let scope = scope_for_minted_token(access_mapping, scope)?;
if !access_mapping
.bounded_context
.same_as(&event_type.bounded_context)
{
return Err(Error::GrantBoundedContextMismatch.into());
}
Ok(DirectCreationToken {
id,
secret,
status: TokenStatus::Active,
created_at: now,
revoked_at: None,
event_type: event_type.clone(),
scope,
})
}
#[allow(clippy::too_many_arguments)]
pub fn create_event_read_token(
access_mapping: &RoleAccessMapping,
event_type: &crate::event_store::EventType,
id: String,
secret: String,
scope: Option<String>,
start_from: Option<EventReadStartPosition>,
start_at_sequence: Option<i64>,
start_at_time: Option<chrono::DateTime<chrono::Utc>>,
now: chrono::DateTime<chrono::Utc>,
) -> crate::error::Result<EventReadToken> {
require_active_admin(access_mapping)?;
let scope = scope_for_minted_token(access_mapping, scope)?;
if !access_mapping
.bounded_context
.same_as(&event_type.bounded_context)
{
return Err(Error::GrantBoundedContextMismatch.into());
}
let resolved_start_from = start_from.unwrap_or(EventReadStartPosition::Beginning);
match resolved_start_from {
EventReadStartPosition::AtSequence => {
if start_at_sequence.is_none() || start_at_time.is_some() {
return Err(Error::StartAtSequenceMismatch.into());
}
}
EventReadStartPosition::AtTime => {
if start_at_time.is_none() || start_at_sequence.is_some() {
return Err(Error::StartAtTimeMismatch.into());
}
}
EventReadStartPosition::Beginning | EventReadStartPosition::Latest => {
if start_at_sequence.is_some() || start_at_time.is_some() {
return Err(Error::StartAtValueNotAllowed.into());
}
}
}
Ok(EventReadToken {
id,
secret,
status: TokenStatus::Active,
created_at: now,
revoked_at: None,
event_type: event_type.clone(),
scope,
start_from: resolved_start_from,
start_at_sequence,
start_at_time,
})
}
pub fn create_command_token(
access_mapping: &RoleAccessMapping,
command_type: &crate::event_store::CommandType,
id: String,
secret: String,
scope: Option<String>,
now: chrono::DateTime<chrono::Utc>,
) -> crate::error::Result<CommandToken> {
require_active_admin(access_mapping)?;
let scope = scope_for_minted_token(access_mapping, scope)?;
if !access_mapping
.bounded_context
.same_as(&command_type.bounded_context)
{
return Err(Error::GrantBoundedContextMismatch.into());
}
Ok(CommandToken {
id,
secret,
status: TokenStatus::Active,
created_at: now,
revoked_at: None,
command_type: command_type.clone(),
scope,
})
}
pub fn revoke_token(
access_mapping: &RoleAccessMapping,
token: &AccessToken,
now: chrono::DateTime<chrono::Utc>,
) -> crate::error::Result<AccessToken> {
require_active_admin(access_mapping)?;
if !access_mapping.bounded_context.same_as(token.scope()) {
return Err(Error::GrantBoundedContextMismatch.into());
}
if access_mapping
.scope
.as_deref()
.is_some_and(|own| token.owner_scope() != Some(own))
{
return Err(Error::TokenScopeBeyondGrant.into());
}
if token.status() != TokenStatus::Active {
return Err(Error::TokenNotActive.into());
}
Ok(match token.clone() {
AccessToken::ExternalEventToken(t) => AccessToken::ExternalEventToken(ExternalEventToken {
status: TokenStatus::Revoked,
revoked_at: Some(now),
..t
}),
AccessToken::DirectCreationToken(t) => {
AccessToken::DirectCreationToken(DirectCreationToken {
status: TokenStatus::Revoked,
revoked_at: Some(now),
..t
})
}
AccessToken::CommandToken(t) => AccessToken::CommandToken(CommandToken {
status: TokenStatus::Revoked,
revoked_at: Some(now),
..t
}),
AccessToken::EventReadToken(t) => AccessToken::EventReadToken(EventReadToken {
status: TokenStatus::Revoked,
revoked_at: Some(now),
..t
}),
})
}