mirror of
https://github.com/zhaofengli/attic.git
synced 2025-03-16 21:38:21 +00:00
Refactor token into a separate crate
This commit is contained in:
parent
c89f5f0f3f
commit
77070b9895
9 changed files with 531 additions and 401 deletions
18
Cargo.lock
generated
18
Cargo.lock
generated
|
@ -199,6 +199,7 @@ dependencies = [
|
||||||
"async-compression",
|
"async-compression",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"attic",
|
"attic",
|
||||||
|
"attic-token",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
"aws-sdk-s3",
|
"aws-sdk-s3",
|
||||||
"axum",
|
"axum",
|
||||||
|
@ -218,7 +219,6 @@ dependencies = [
|
||||||
"humantime-serde",
|
"humantime-serde",
|
||||||
"itoa",
|
"itoa",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"lazy_static",
|
|
||||||
"maybe-owned",
|
"maybe-owned",
|
||||||
"rand",
|
"rand",
|
||||||
"regex",
|
"regex",
|
||||||
|
@ -240,6 +240,22 @@ dependencies = [
|
||||||
"xdg",
|
"xdg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "attic-token"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"attic",
|
||||||
|
"base64 0.20.0",
|
||||||
|
"chrono",
|
||||||
|
"displaydoc",
|
||||||
|
"jsonwebtoken",
|
||||||
|
"lazy_static",
|
||||||
|
"regex",
|
||||||
|
"serde",
|
||||||
|
"serde_with",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atty"
|
name = "atty"
|
||||||
version = "0.2.14"
|
version = "0.2.14"
|
||||||
|
|
|
@ -4,4 +4,5 @@ members = [
|
||||||
"attic",
|
"attic",
|
||||||
"client",
|
"client",
|
||||||
"server",
|
"server",
|
||||||
|
"token",
|
||||||
]
|
]
|
||||||
|
|
|
@ -20,6 +20,7 @@ doc = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
attic = { path = "../attic", default-features = false }
|
attic = { path = "../attic", default-features = false }
|
||||||
|
attic-token = { path = "../token" }
|
||||||
|
|
||||||
anyhow = "1.0.68"
|
anyhow = "1.0.68"
|
||||||
async-trait = "0.1.60"
|
async-trait = "0.1.60"
|
||||||
|
@ -41,7 +42,6 @@ humantime = "2.1.0"
|
||||||
humantime-serde = "1.1.1"
|
humantime-serde = "1.1.1"
|
||||||
itoa = "1.0.5"
|
itoa = "1.0.5"
|
||||||
jsonwebtoken = "8.2.0"
|
jsonwebtoken = "8.2.0"
|
||||||
lazy_static = "1.4.0"
|
|
||||||
maybe-owned = "0.3.4"
|
maybe-owned = "0.3.4"
|
||||||
rand = "0.8.5"
|
rand = "0.8.5"
|
||||||
regex = "1.7.0"
|
regex = "1.7.0"
|
||||||
|
|
|
@ -1,10 +1,6 @@
|
||||||
//! HTTP middlewares for access control.
|
//! HTTP middlewares for access control.
|
||||||
|
|
||||||
use std::str;
|
|
||||||
|
|
||||||
use axum::{http::Request, middleware::Next, response::Response};
|
use axum::{http::Request, middleware::Next, response::Response};
|
||||||
use lazy_static::lazy_static;
|
|
||||||
use regex::Regex;
|
|
||||||
use sea_orm::DatabaseConnection;
|
use sea_orm::DatabaseConnection;
|
||||||
use tokio::sync::OnceCell;
|
use tokio::sync::OnceCell;
|
||||||
|
|
||||||
|
@ -13,11 +9,7 @@ use crate::database::{entity::cache::CacheModel, AtticDatabase};
|
||||||
use crate::error::ServerResult;
|
use crate::error::ServerResult;
|
||||||
use crate::{RequestState, State};
|
use crate::{RequestState, State};
|
||||||
use attic::cache::CacheName;
|
use attic::cache::CacheName;
|
||||||
|
use attic_token::util::parse_authorization_header;
|
||||||
lazy_static! {
|
|
||||||
static ref AUTHORIZATION_REGEX: Regex =
|
|
||||||
Regex::new(r"^(?i)((?P<bearer>bearer)|(?P<basic>basic))(?-i) (?P<rest>(.*))$").unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Auth state.
|
/// Auth state.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
@ -124,46 +116,3 @@ pub async fn apply_auth<B>(req: Request<B>, next: Next<B>) -> Response {
|
||||||
|
|
||||||
next.run(req).await
|
next.run(req).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extracts the JWT from an Authorization header.
|
|
||||||
fn parse_authorization_header(authorization: &str) -> Option<String> {
|
|
||||||
let captures = AUTHORIZATION_REGEX.captures(authorization)?;
|
|
||||||
let rest = captures.name("rest").unwrap().as_str();
|
|
||||||
|
|
||||||
if captures.name("bearer").is_some() {
|
|
||||||
// Bearer token
|
|
||||||
Some(rest.to_string())
|
|
||||||
} else {
|
|
||||||
// Basic auth
|
|
||||||
let bytes = base64::decode(rest).ok()?;
|
|
||||||
|
|
||||||
let user_pass = str::from_utf8(&bytes).ok()?;
|
|
||||||
let colon = user_pass.find(':')?;
|
|
||||||
let pass = &user_pass[colon + 1..];
|
|
||||||
|
|
||||||
Some(pass.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_authorization_header() {
|
|
||||||
assert_eq!(
|
|
||||||
"somepass",
|
|
||||||
parse_authorization_header("Basic c29tZXVzZXI6c29tZXBhc3M=").unwrap(),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
"somepass",
|
|
||||||
parse_authorization_header("baSIC c29tZXVzZXI6c29tZXBhc3M=").unwrap(),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
"some-token",
|
|
||||||
parse_authorization_header("bearer some-token").unwrap(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,352 +1,7 @@
|
||||||
//! Access control.
|
//! Access control.
|
||||||
//!
|
//!
|
||||||
//! Access control in Attic is simple and stateless [0] - The server validates
|
//! See [attic_token] for more details.
|
||||||
//! the JWT against a trusted public key and allows access based on the
|
|
||||||
//! `x-attic-access` claim.
|
|
||||||
//!
|
|
||||||
//! One primary goal of the Attic Server is easy scalability. It's designed
|
|
||||||
//! to be deployed to serverless platforms like AWS Lambda and have fast
|
|
||||||
//! cold-start times. Instances are created and destoyed rapidly in response
|
|
||||||
//! to requests.
|
|
||||||
//!
|
|
||||||
//! [0] We may revisit this later :)
|
|
||||||
//!
|
|
||||||
//! ## Cache discovery
|
|
||||||
//!
|
|
||||||
//! If the JWT grants any permission at all to the requested cache name,
|
|
||||||
//! then the bearer is able to discover the presence of the cache, meaning
|
|
||||||
//! that NoSuchCache or Forbidden can be returned depending on the scenario.
|
|
||||||
//! Otherwise, the user will get a generic 401 response (Unauthorized)
|
|
||||||
//! regardless of the request (or whether the cache exists or not).
|
|
||||||
//!
|
|
||||||
//! ## Supplying the token
|
|
||||||
//!
|
|
||||||
//! The JWT can be supplied to the server in one of two ways:
|
|
||||||
//!
|
|
||||||
//! - As a normal Bearer token.
|
|
||||||
//! - As the password in Basic Auth (used by Nix). The username is ignored.
|
|
||||||
//!
|
|
||||||
//! To add the token to Nix, use the following format in `~/.config/nix/netrc`:
|
|
||||||
//!
|
|
||||||
//! ```text
|
|
||||||
//! machine attic.server.tld password eyJhb...
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! ## Example token
|
|
||||||
//!
|
|
||||||
//! ```json
|
|
||||||
//! {
|
|
||||||
//! "sub": "meow",
|
|
||||||
//! "exp": 4102324986,
|
|
||||||
//! "https://jwt.attic.rs/v1": {
|
|
||||||
//! "caches": {
|
|
||||||
//! "cache-rw": {
|
|
||||||
//! "w": 1,
|
|
||||||
//! "r": 1
|
|
||||||
//! },
|
|
||||||
//! "cache-ro": {
|
|
||||||
//! "r": 1
|
|
||||||
//! },
|
|
||||||
//! "team-*": {
|
|
||||||
//! "w": 1,
|
|
||||||
//! "r": 1,
|
|
||||||
//! "cc": 1
|
|
||||||
//! }
|
|
||||||
//! }
|
|
||||||
//! }
|
|
||||||
//! }
|
|
||||||
//! ```
|
|
||||||
|
|
||||||
pub mod http;
|
pub mod http;
|
||||||
|
|
||||||
#[cfg(test)]
|
pub use attic_token::*;
|
||||||
mod tests;
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use displaydoc::Display;
|
|
||||||
pub use jsonwebtoken::{
|
|
||||||
Algorithm as JwtAlgorithm, DecodingKey as JwtDecodingKey, EncodingKey as JwtEncodingKey,
|
|
||||||
Header as JwtHeader, Validation as JwtValidation,
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_with::{serde_as, BoolFromInt};
|
|
||||||
|
|
||||||
use crate::error::ServerResult;
|
|
||||||
use attic::cache::{CacheName, CacheNamePattern};
|
|
||||||
|
|
||||||
/// Custom claim namespace for the AtticAccess information.
|
|
||||||
///
|
|
||||||
/// Custom claim namespaces are required by platforms like Auth0, and
|
|
||||||
/// custom claims without one will be silently dropped.
|
|
||||||
///
|
|
||||||
/// <https://auth0.com/docs/security/tokens/json-web-tokens/create-namespaced-custom-claims>
|
|
||||||
///
|
|
||||||
/// Also change the `#[serde(rename)]` below if you change this.
|
|
||||||
pub const CLAIM_NAMESPACE: &str = "https://jwt.attic.rs/v1";
|
|
||||||
|
|
||||||
macro_rules! require_permission_function {
|
|
||||||
($name:ident, $descr:literal, $member:ident) => {
|
|
||||||
pub fn $name(&self) -> ServerResult<()> {
|
|
||||||
if !self.$member {
|
|
||||||
tracing::debug!("Client has no {} permission", $descr);
|
|
||||||
if self.can_discover() {
|
|
||||||
Err(Error::PermissionDenied.into())
|
|
||||||
} else {
|
|
||||||
Err(Error::NoDiscoveryPermission.into())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A validated JSON Web Token.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Token(jsonwebtoken::TokenData<TokenClaims>);
|
|
||||||
|
|
||||||
/// Claims of a JSON Web Token.
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
struct TokenClaims {
|
|
||||||
/// Subject.
|
|
||||||
sub: String,
|
|
||||||
|
|
||||||
/// Expiration timestamp.
|
|
||||||
exp: usize,
|
|
||||||
|
|
||||||
/// Attic namespace.
|
|
||||||
#[serde(rename = "https://jwt.attic.rs/v1")]
|
|
||||||
attic_ns: AtticAccess,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Permissions granted to a client.
|
|
||||||
///
|
|
||||||
/// This is the content of the `attic-access` claim in JWTs.
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct AtticAccess {
|
|
||||||
/// Cache permissions.
|
|
||||||
///
|
|
||||||
/// Keys here may include wildcards.
|
|
||||||
caches: HashMap<CacheNamePattern, CachePermission>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Permission to a single cache.
|
|
||||||
#[serde_as]
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct CachePermission {
|
|
||||||
/// Can pull objects from the cache.
|
|
||||||
#[serde(default = "CachePermission::permission_default")]
|
|
||||||
#[serde(skip_serializing_if = "is_false")]
|
|
||||||
#[serde(rename = "r")]
|
|
||||||
#[serde_as(as = "BoolFromInt")]
|
|
||||||
pub pull: bool,
|
|
||||||
|
|
||||||
/// Can push objects to the cache.
|
|
||||||
#[serde(default = "CachePermission::permission_default")]
|
|
||||||
#[serde(skip_serializing_if = "is_false")]
|
|
||||||
#[serde(rename = "w")]
|
|
||||||
#[serde_as(as = "BoolFromInt")]
|
|
||||||
pub push: bool,
|
|
||||||
|
|
||||||
/// Can delete objects from the cache.
|
|
||||||
#[serde(default = "CachePermission::permission_default")]
|
|
||||||
#[serde(skip_serializing_if = "is_false")]
|
|
||||||
#[serde(rename = "d")]
|
|
||||||
#[serde_as(as = "BoolFromInt")]
|
|
||||||
pub delete: bool,
|
|
||||||
|
|
||||||
/// Can create the cache itself.
|
|
||||||
#[serde(default = "CachePermission::permission_default")]
|
|
||||||
#[serde(skip_serializing_if = "is_false")]
|
|
||||||
#[serde(rename = "cc")]
|
|
||||||
#[serde_as(as = "BoolFromInt")]
|
|
||||||
pub create_cache: bool,
|
|
||||||
|
|
||||||
/// Can reconfigure the cache.
|
|
||||||
#[serde(default = "CachePermission::permission_default")]
|
|
||||||
#[serde(skip_serializing_if = "is_false")]
|
|
||||||
#[serde(rename = "cr")]
|
|
||||||
#[serde_as(as = "BoolFromInt")]
|
|
||||||
pub configure_cache: bool,
|
|
||||||
|
|
||||||
/// Can configure retention/quota settings.
|
|
||||||
#[serde(default = "CachePermission::permission_default")]
|
|
||||||
#[serde(skip_serializing_if = "is_false")]
|
|
||||||
#[serde(rename = "cq")]
|
|
||||||
#[serde_as(as = "BoolFromInt")]
|
|
||||||
pub configure_cache_retention: bool,
|
|
||||||
|
|
||||||
/// Can destroy the cache itself.
|
|
||||||
#[serde(default = "CachePermission::permission_default")]
|
|
||||||
#[serde(skip_serializing_if = "is_false")]
|
|
||||||
#[serde(rename = "cd")]
|
|
||||||
#[serde_as(as = "BoolFromInt")]
|
|
||||||
pub destroy_cache: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An access error.
|
|
||||||
#[derive(Debug, Display)]
|
|
||||||
#[ignore_extra_doc_attributes]
|
|
||||||
pub enum Error {
|
|
||||||
/// User has no permission to this cache.
|
|
||||||
NoDiscoveryPermission,
|
|
||||||
|
|
||||||
/// User does not have permission to complete this action.
|
|
||||||
///
|
|
||||||
/// This implies that there is some permission granted to the
|
|
||||||
/// user, so the user is authorized to discover the cache.
|
|
||||||
PermissionDenied,
|
|
||||||
|
|
||||||
/// JWT error: {0}
|
|
||||||
TokenError(jsonwebtoken::errors::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Token {
|
|
||||||
/// Verifies and decodes a token.
|
|
||||||
pub fn from_jwt(token: &str, key: &JwtDecodingKey) -> ServerResult<Self> {
|
|
||||||
let validation = JwtValidation::default();
|
|
||||||
jsonwebtoken::decode::<TokenClaims>(token, key, &validation)
|
|
||||||
.map_err(|e| Error::TokenError(e).into())
|
|
||||||
.map(Token)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates a new token with an expiration timestamp.
|
|
||||||
pub fn new(sub: String, exp: &DateTime<Utc>) -> Self {
|
|
||||||
let claims = TokenClaims {
|
|
||||||
sub,
|
|
||||||
exp: exp.timestamp() as usize,
|
|
||||||
attic_ns: Default::default(),
|
|
||||||
};
|
|
||||||
|
|
||||||
Self(jsonwebtoken::TokenData {
|
|
||||||
header: JwtHeader::new(JwtAlgorithm::HS256),
|
|
||||||
claims,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encodes the token.
|
|
||||||
pub fn encode(&self, key: &JwtEncodingKey) -> ServerResult<String> {
|
|
||||||
jsonwebtoken::encode(&self.0.header, &self.0.claims, key)
|
|
||||||
.map_err(|e| Error::TokenError(e).into())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the subject of the token.
|
|
||||||
pub fn sub(&self) -> &str {
|
|
||||||
self.0.claims.sub.as_str()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the claims as a serializable value.
|
|
||||||
pub fn opaque_claims(&self) -> &impl Serialize {
|
|
||||||
&self.0.claims
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a mutable reference to a permission entry.
|
|
||||||
pub fn get_or_insert_permission_mut(
|
|
||||||
&mut self,
|
|
||||||
pattern: CacheNamePattern,
|
|
||||||
) -> &mut CachePermission {
|
|
||||||
use std::collections::hash_map::Entry;
|
|
||||||
|
|
||||||
let access = self.attic_access_mut();
|
|
||||||
match access.caches.entry(pattern) {
|
|
||||||
Entry::Occupied(v) => v.into_mut(),
|
|
||||||
Entry::Vacant(v) => v.insert(CachePermission::default()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns explicit permission granted for a cache.
|
|
||||||
pub fn get_permission_for_cache(&self, cache: &CacheName) -> CachePermission {
|
|
||||||
let access = self.attic_access();
|
|
||||||
|
|
||||||
let pattern_key = cache.to_pattern();
|
|
||||||
if let Some(direct_match) = access.caches.get(&pattern_key) {
|
|
||||||
return direct_match.clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
for (pattern, permission) in access.caches.iter() {
|
|
||||||
if pattern.matches(cache) {
|
|
||||||
return permission.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CachePermission::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn attic_access(&self) -> &AtticAccess {
|
|
||||||
&self.0.claims.attic_ns
|
|
||||||
}
|
|
||||||
|
|
||||||
fn attic_access_mut(&mut self) -> &mut AtticAccess {
|
|
||||||
&mut self.0.claims.attic_ns
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CachePermission {
|
|
||||||
/// Adds implicit grants for public caches.
|
|
||||||
pub fn add_public_permissions(&mut self) {
|
|
||||||
self.pull = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns whether the user is allowed to discover this cache.
|
|
||||||
///
|
|
||||||
/// This permission is implied when any permission is explicitly
|
|
||||||
/// granted.
|
|
||||||
pub const fn can_discover(&self) -> bool {
|
|
||||||
self.push
|
|
||||||
|| self.pull
|
|
||||||
|| self.delete
|
|
||||||
|| self.create_cache
|
|
||||||
|| self.configure_cache
|
|
||||||
|| self.destroy_cache
|
|
||||||
|| self.configure_cache_retention
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn require_discover(&self) -> ServerResult<()> {
|
|
||||||
if !self.can_discover() {
|
|
||||||
Err(Error::NoDiscoveryPermission.into())
|
|
||||||
} else {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
require_permission_function!(require_pull, "pull", pull);
|
|
||||||
require_permission_function!(require_push, "push", push);
|
|
||||||
require_permission_function!(require_delete, "delete", delete);
|
|
||||||
require_permission_function!(require_create_cache, "create cache", create_cache);
|
|
||||||
require_permission_function!(
|
|
||||||
require_configure_cache,
|
|
||||||
"reconfigure cache",
|
|
||||||
configure_cache
|
|
||||||
);
|
|
||||||
require_permission_function!(
|
|
||||||
require_configure_cache_retention,
|
|
||||||
"configure cache retention",
|
|
||||||
configure_cache_retention
|
|
||||||
);
|
|
||||||
require_permission_function!(require_destroy_cache, "destroy cache", destroy_cache);
|
|
||||||
|
|
||||||
fn permission_default() -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for CachePermission {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
pull: false,
|
|
||||||
push: false,
|
|
||||||
delete: false,
|
|
||||||
create_cache: false,
|
|
||||||
configure_cache: false,
|
|
||||||
configure_cache_retention: false,
|
|
||||||
destroy_cache: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// bruh
|
|
||||||
fn is_false(b: &bool) -> bool {
|
|
||||||
!b
|
|
||||||
}
|
|
||||||
|
|
19
token/Cargo.toml
Normal file
19
token/Cargo.toml
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
[package]
|
||||||
|
name = "attic-token"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
attic = { path = "../attic", default-features = false }
|
||||||
|
|
||||||
|
base64 = "0.20.0"
|
||||||
|
chrono = "0.4.23"
|
||||||
|
displaydoc = "0.2.3"
|
||||||
|
jsonwebtoken = "8.2.0"
|
||||||
|
lazy_static = "1.4.0"
|
||||||
|
regex = "1.7.0"
|
||||||
|
serde = "1.0.151"
|
||||||
|
serde_with = "2.1.0"
|
||||||
|
tracing = "0.1.37"
|
362
token/src/lib.rs
Normal file
362
token/src/lib.rs
Normal file
|
@ -0,0 +1,362 @@
|
||||||
|
//! Access control.
|
||||||
|
//!
|
||||||
|
//! Access control in Attic is simple and stateless [0] - The server validates
|
||||||
|
//! the JWT against a trusted public key and allows access based on the
|
||||||
|
//! `x-attic-access` claim.
|
||||||
|
//!
|
||||||
|
//! One primary goal of the Attic Server is easy scalability. It's designed
|
||||||
|
//! to be deployed to serverless platforms like AWS Lambda and have fast
|
||||||
|
//! cold-start times. Instances are created and destoyed rapidly in response
|
||||||
|
//! to requests.
|
||||||
|
//!
|
||||||
|
//! [0] We may revisit this later :)
|
||||||
|
//!
|
||||||
|
//! ## Opaqueness
|
||||||
|
//!
|
||||||
|
//! The token format is unstable and claims beyond the standard ones defined
|
||||||
|
//! in RFC 7519 should never be interpreted by the client. The token might not
|
||||||
|
//! even be a valid JWT, in which case the client must not throw an error.
|
||||||
|
//!
|
||||||
|
//! ## Cache discovery
|
||||||
|
//!
|
||||||
|
//! If the JWT grants any permission at all to the requested cache name,
|
||||||
|
//! then the bearer is able to discover the presence of the cache, meaning
|
||||||
|
//! that NoSuchCache or Forbidden can be returned depending on the scenario.
|
||||||
|
//! Otherwise, the user will get a generic 401 response (Unauthorized)
|
||||||
|
//! regardless of the request (or whether the cache exists or not).
|
||||||
|
//!
|
||||||
|
//! ## Supplying the token
|
||||||
|
//!
|
||||||
|
//! The JWT can be supplied to the server in one of two ways:
|
||||||
|
//!
|
||||||
|
//! - As a normal Bearer token.
|
||||||
|
//! - As the password in Basic Auth (used by Nix). The username is ignored.
|
||||||
|
//!
|
||||||
|
//! To add the token to Nix, use the following format in `~/.config/nix/netrc`:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! machine attic.server.tld password eyJhb...
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Example token
|
||||||
|
//!
|
||||||
|
//! ```json
|
||||||
|
//! {
|
||||||
|
//! "sub": "meow",
|
||||||
|
//! "exp": 4102324986,
|
||||||
|
//! "https://jwt.attic.rs/v1": {
|
||||||
|
//! "caches": {
|
||||||
|
//! "cache-rw": {
|
||||||
|
//! "w": 1,
|
||||||
|
//! "r": 1
|
||||||
|
//! },
|
||||||
|
//! "cache-ro": {
|
||||||
|
//! "r": 1
|
||||||
|
//! },
|
||||||
|
//! "team-*": {
|
||||||
|
//! "w": 1,
|
||||||
|
//! "r": 1,
|
||||||
|
//! "cc": 1
|
||||||
|
//! }
|
||||||
|
//! }
|
||||||
|
//! }
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
pub mod util;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::error::Error as StdError;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use displaydoc::Display;
|
||||||
|
pub use jsonwebtoken::{
|
||||||
|
Algorithm as JwtAlgorithm, DecodingKey as JwtDecodingKey, EncodingKey as JwtEncodingKey,
|
||||||
|
Header as JwtHeader, Validation as JwtValidation,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_with::{serde_as, BoolFromInt};
|
||||||
|
|
||||||
|
use attic::cache::{CacheName, CacheNamePattern};
|
||||||
|
|
||||||
|
/// Custom claim namespace for the AtticAccess information.
|
||||||
|
///
|
||||||
|
/// Custom claim namespaces are required by platforms like Auth0, and
|
||||||
|
/// custom claims without one will be silently dropped.
|
||||||
|
///
|
||||||
|
/// <https://auth0.com/docs/security/tokens/json-web-tokens/create-namespaced-custom-claims>
|
||||||
|
///
|
||||||
|
/// Also change the `#[serde(rename)]` below if you change this.
|
||||||
|
pub const CLAIM_NAMESPACE: &str = "https://jwt.attic.rs/v1";
|
||||||
|
|
||||||
|
macro_rules! require_permission_function {
|
||||||
|
($name:ident, $descr:literal, $member:ident) => {
|
||||||
|
pub fn $name(&self) -> Result<()> {
|
||||||
|
if !self.$member {
|
||||||
|
tracing::debug!("Client has no {} permission", $descr);
|
||||||
|
if self.can_discover() {
|
||||||
|
Err(Error::PermissionDenied)
|
||||||
|
} else {
|
||||||
|
Err(Error::NoDiscoveryPermission)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A validated JSON Web Token.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Token(jsonwebtoken::TokenData<TokenClaims>);
|
||||||
|
|
||||||
|
/// Claims of a JSON Web Token.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct TokenClaims {
|
||||||
|
/// Subject.
|
||||||
|
sub: String,
|
||||||
|
|
||||||
|
/// Expiration timestamp.
|
||||||
|
exp: usize,
|
||||||
|
|
||||||
|
/// Attic namespace.
|
||||||
|
#[serde(rename = "https://jwt.attic.rs/v1")]
|
||||||
|
attic_ns: AtticAccess,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Permissions granted to a client.
|
||||||
|
///
|
||||||
|
/// This is the content of the `attic-access` claim in JWTs.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct AtticAccess {
|
||||||
|
/// Cache permissions.
|
||||||
|
///
|
||||||
|
/// Keys here may include wildcards.
|
||||||
|
caches: HashMap<CacheNamePattern, CachePermission>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Permission to a single cache.
|
||||||
|
#[serde_as]
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CachePermission {
|
||||||
|
/// Can pull objects from the cache.
|
||||||
|
#[serde(default = "CachePermission::permission_default")]
|
||||||
|
#[serde(skip_serializing_if = "is_false")]
|
||||||
|
#[serde(rename = "r")]
|
||||||
|
#[serde_as(as = "BoolFromInt")]
|
||||||
|
pub pull: bool,
|
||||||
|
|
||||||
|
/// Can push objects to the cache.
|
||||||
|
#[serde(default = "CachePermission::permission_default")]
|
||||||
|
#[serde(skip_serializing_if = "is_false")]
|
||||||
|
#[serde(rename = "w")]
|
||||||
|
#[serde_as(as = "BoolFromInt")]
|
||||||
|
pub push: bool,
|
||||||
|
|
||||||
|
/// Can delete objects from the cache.
|
||||||
|
#[serde(default = "CachePermission::permission_default")]
|
||||||
|
#[serde(skip_serializing_if = "is_false")]
|
||||||
|
#[serde(rename = "d")]
|
||||||
|
#[serde_as(as = "BoolFromInt")]
|
||||||
|
pub delete: bool,
|
||||||
|
|
||||||
|
/// Can create the cache itself.
|
||||||
|
#[serde(default = "CachePermission::permission_default")]
|
||||||
|
#[serde(skip_serializing_if = "is_false")]
|
||||||
|
#[serde(rename = "cc")]
|
||||||
|
#[serde_as(as = "BoolFromInt")]
|
||||||
|
pub create_cache: bool,
|
||||||
|
|
||||||
|
/// Can reconfigure the cache.
|
||||||
|
#[serde(default = "CachePermission::permission_default")]
|
||||||
|
#[serde(skip_serializing_if = "is_false")]
|
||||||
|
#[serde(rename = "cr")]
|
||||||
|
#[serde_as(as = "BoolFromInt")]
|
||||||
|
pub configure_cache: bool,
|
||||||
|
|
||||||
|
/// Can configure retention/quota settings.
|
||||||
|
#[serde(default = "CachePermission::permission_default")]
|
||||||
|
#[serde(skip_serializing_if = "is_false")]
|
||||||
|
#[serde(rename = "cq")]
|
||||||
|
#[serde_as(as = "BoolFromInt")]
|
||||||
|
pub configure_cache_retention: bool,
|
||||||
|
|
||||||
|
/// Can destroy the cache itself.
|
||||||
|
#[serde(default = "CachePermission::permission_default")]
|
||||||
|
#[serde(skip_serializing_if = "is_false")]
|
||||||
|
#[serde(rename = "cd")]
|
||||||
|
#[serde_as(as = "BoolFromInt")]
|
||||||
|
pub destroy_cache: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
/// An access error.
|
||||||
|
#[derive(Debug, Display)]
|
||||||
|
#[ignore_extra_doc_attributes]
|
||||||
|
pub enum Error {
|
||||||
|
/// User has no permission to this cache.
|
||||||
|
NoDiscoveryPermission,
|
||||||
|
|
||||||
|
/// User does not have permission to complete this action.
|
||||||
|
///
|
||||||
|
/// This implies that there is some permission granted to the
|
||||||
|
/// user, so the user is authorized to discover the cache.
|
||||||
|
PermissionDenied,
|
||||||
|
|
||||||
|
/// JWT error: {0}
|
||||||
|
TokenError(jsonwebtoken::errors::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Token {
|
||||||
|
/// Verifies and decodes a token.
|
||||||
|
pub fn from_jwt(token: &str, key: &JwtDecodingKey) -> Result<Self> {
|
||||||
|
let validation = JwtValidation::default();
|
||||||
|
jsonwebtoken::decode::<TokenClaims>(token, key, &validation)
|
||||||
|
.map_err(|e| Error::TokenError(e))
|
||||||
|
.map(Token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new token with an expiration timestamp.
|
||||||
|
pub fn new(sub: String, exp: &DateTime<Utc>) -> Self {
|
||||||
|
let claims = TokenClaims {
|
||||||
|
sub,
|
||||||
|
exp: exp.timestamp() as usize,
|
||||||
|
attic_ns: Default::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Self(jsonwebtoken::TokenData {
|
||||||
|
header: JwtHeader::new(JwtAlgorithm::HS256),
|
||||||
|
claims,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes the token.
|
||||||
|
pub fn encode(&self, key: &JwtEncodingKey) -> Result<String> {
|
||||||
|
jsonwebtoken::encode(&self.0.header, &self.0.claims, key)
|
||||||
|
.map_err(|e| Error::TokenError(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the subject of the token.
|
||||||
|
pub fn sub(&self) -> &str {
|
||||||
|
self.0.claims.sub.as_str()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the claims as a serializable value.
|
||||||
|
pub fn opaque_claims(&self) -> &impl Serialize {
|
||||||
|
&self.0.claims
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a mutable reference to a permission entry.
|
||||||
|
pub fn get_or_insert_permission_mut(
|
||||||
|
&mut self,
|
||||||
|
pattern: CacheNamePattern,
|
||||||
|
) -> &mut CachePermission {
|
||||||
|
use std::collections::hash_map::Entry;
|
||||||
|
|
||||||
|
let access = self.attic_access_mut();
|
||||||
|
match access.caches.entry(pattern) {
|
||||||
|
Entry::Occupied(v) => v.into_mut(),
|
||||||
|
Entry::Vacant(v) => v.insert(CachePermission::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns explicit permission granted for a cache.
|
||||||
|
pub fn get_permission_for_cache(&self, cache: &CacheName) -> CachePermission {
|
||||||
|
let access = self.attic_access();
|
||||||
|
|
||||||
|
let pattern_key = cache.to_pattern();
|
||||||
|
if let Some(direct_match) = access.caches.get(&pattern_key) {
|
||||||
|
return direct_match.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (pattern, permission) in access.caches.iter() {
|
||||||
|
if pattern.matches(cache) {
|
||||||
|
return permission.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CachePermission::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attic_access(&self) -> &AtticAccess {
|
||||||
|
&self.0.claims.attic_ns
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attic_access_mut(&mut self) -> &mut AtticAccess {
|
||||||
|
&mut self.0.claims.attic_ns
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CachePermission {
|
||||||
|
/// Adds implicit grants for public caches.
|
||||||
|
pub fn add_public_permissions(&mut self) {
|
||||||
|
self.pull = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the user is allowed to discover this cache.
|
||||||
|
///
|
||||||
|
/// This permission is implied when any permission is explicitly
|
||||||
|
/// granted.
|
||||||
|
pub const fn can_discover(&self) -> bool {
|
||||||
|
self.push
|
||||||
|
|| self.pull
|
||||||
|
|| self.delete
|
||||||
|
|| self.create_cache
|
||||||
|
|| self.configure_cache
|
||||||
|
|| self.destroy_cache
|
||||||
|
|| self.configure_cache_retention
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn require_discover(&self) -> Result<()> {
|
||||||
|
if !self.can_discover() {
|
||||||
|
Err(Error::NoDiscoveryPermission)
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require_permission_function!(require_pull, "pull", pull);
|
||||||
|
require_permission_function!(require_push, "push", push);
|
||||||
|
require_permission_function!(require_delete, "delete", delete);
|
||||||
|
require_permission_function!(require_create_cache, "create cache", create_cache);
|
||||||
|
require_permission_function!(
|
||||||
|
require_configure_cache,
|
||||||
|
"reconfigure cache",
|
||||||
|
configure_cache
|
||||||
|
);
|
||||||
|
require_permission_function!(
|
||||||
|
require_configure_cache_retention,
|
||||||
|
"configure cache retention",
|
||||||
|
configure_cache_retention
|
||||||
|
);
|
||||||
|
require_permission_function!(require_destroy_cache, "destroy cache", destroy_cache);
|
||||||
|
|
||||||
|
fn permission_default() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CachePermission {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
pull: false,
|
||||||
|
push: false,
|
||||||
|
delete: false,
|
||||||
|
create_cache: false,
|
||||||
|
configure_cache: false,
|
||||||
|
configure_cache_retention: false,
|
||||||
|
destroy_cache: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StdError for Error {}
|
||||||
|
|
||||||
|
// bruh
|
||||||
|
fn is_false(b: &bool) -> bool {
|
||||||
|
!b
|
||||||
|
}
|
76
token/src/tests.rs
Normal file
76
token/src/tests.rs
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use attic::cache::CacheName;
|
||||||
|
|
||||||
|
macro_rules! cache {
|
||||||
|
($n:expr) => {
|
||||||
|
CacheName::new($n.to_string()).unwrap()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_basic() {
|
||||||
|
// "very secure secret"
|
||||||
|
let base64_secret = "dmVyeSBzZWN1cmUgc2VjcmV0";
|
||||||
|
|
||||||
|
let dec_key =
|
||||||
|
JwtDecodingKey::from_base64_secret(base64_secret).expect("Could not import decoding key");
|
||||||
|
|
||||||
|
/*
|
||||||
|
{
|
||||||
|
"sub": "meow",
|
||||||
|
"exp": 4102324986,
|
||||||
|
"https://jwt.attic.rs/v1": {
|
||||||
|
"caches": {
|
||||||
|
"cache-rw": {"r":1,"w":1},
|
||||||
|
"cache-ro": {"r":1},
|
||||||
|
"team-*": {"r":1,"w":1,"cc":1}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtZW93IiwiZXhwIjo0MTAyMzI0OTg2LCJodHRwczovL2p3dC5hdHRpYy5ycy92MSI6eyJjYWNoZXMiOnsiY2FjaGUtcnciOnsiciI6MSwidyI6MX0sImNhY2hlLXJvIjp7InIiOjF9LCJ0ZWFtLSoiOnsiciI6MSwidyI6MSwiY2MiOjF9fX19.UlsIM9bQHr9SXGAcSQcoVPo9No8Zhh6Y5xfX8vCmKmA";
|
||||||
|
|
||||||
|
let decoded = Token::from_jwt(token, &dec_key).unwrap();
|
||||||
|
|
||||||
|
let perm_rw = decoded.get_permission_for_cache(&cache! { "cache-rw" });
|
||||||
|
|
||||||
|
assert!(perm_rw.pull);
|
||||||
|
assert!(perm_rw.push);
|
||||||
|
assert!(!perm_rw.delete);
|
||||||
|
assert!(!perm_rw.create_cache);
|
||||||
|
|
||||||
|
assert!(perm_rw.require_pull().is_ok());
|
||||||
|
assert!(perm_rw.require_push().is_ok());
|
||||||
|
assert!(perm_rw.require_delete().is_err());
|
||||||
|
assert!(perm_rw.require_create_cache().is_err());
|
||||||
|
|
||||||
|
let perm_ro = decoded.get_permission_for_cache(&cache! { "cache-ro" });
|
||||||
|
|
||||||
|
assert!(perm_ro.pull);
|
||||||
|
assert!(!perm_ro.push);
|
||||||
|
assert!(!perm_ro.delete);
|
||||||
|
assert!(!perm_ro.create_cache);
|
||||||
|
|
||||||
|
assert!(perm_ro.require_pull().is_ok());
|
||||||
|
assert!(perm_ro.require_push().is_err());
|
||||||
|
assert!(perm_ro.require_delete().is_err());
|
||||||
|
assert!(perm_ro.require_create_cache().is_err());
|
||||||
|
|
||||||
|
let perm_team = decoded.get_permission_for_cache(&cache! { "team-xyz" });
|
||||||
|
|
||||||
|
assert!(perm_team.pull);
|
||||||
|
assert!(perm_team.push);
|
||||||
|
assert!(!perm_team.delete);
|
||||||
|
assert!(perm_team.create_cache);
|
||||||
|
|
||||||
|
assert!(perm_team.require_pull().is_ok());
|
||||||
|
assert!(perm_team.require_push().is_ok());
|
||||||
|
assert!(perm_team.require_delete().is_err());
|
||||||
|
assert!(perm_team.require_create_cache().is_ok());
|
||||||
|
|
||||||
|
assert!(!decoded
|
||||||
|
.get_permission_for_cache(&cache! { "forbidden-cache" })
|
||||||
|
.can_discover());
|
||||||
|
}
|
52
token/src/util.rs
Normal file
52
token/src/util.rs
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
use std::str;
|
||||||
|
|
||||||
|
use lazy_static::lazy_static;
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
static ref AUTHORIZATION_REGEX: Regex =
|
||||||
|
Regex::new(r"^(?i)((?P<bearer>bearer)|(?P<basic>basic))(?-i) (?P<rest>(.*))$").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts the JWT from an Authorization header.
|
||||||
|
pub fn parse_authorization_header(authorization: &str) -> Option<String> {
|
||||||
|
let captures = AUTHORIZATION_REGEX.captures(authorization)?;
|
||||||
|
let rest = captures.name("rest").unwrap().as_str();
|
||||||
|
|
||||||
|
if captures.name("bearer").is_some() {
|
||||||
|
// Bearer token
|
||||||
|
Some(rest.to_string())
|
||||||
|
} else {
|
||||||
|
// Basic auth
|
||||||
|
let bytes = base64::decode(rest).ok()?;
|
||||||
|
|
||||||
|
let user_pass = str::from_utf8(&bytes).ok()?;
|
||||||
|
let colon = user_pass.find(':')?;
|
||||||
|
let pass = &user_pass[colon + 1..];
|
||||||
|
|
||||||
|
Some(pass.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_authorization_header() {
|
||||||
|
assert_eq!(
|
||||||
|
"somepass",
|
||||||
|
parse_authorization_header("Basic c29tZXVzZXI6c29tZXBhc3M=").unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
"somepass",
|
||||||
|
parse_authorization_header("baSIC c29tZXVzZXI6c29tZXBhc3M=").unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
"some-token",
|
||||||
|
parse_authorization_header("bearer some-token").unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue