docs: API.md + RBAC.md with Authentik integration

Documentation:
- docs/API.md: Complete API reference with examples
  - All endpoints with curl examples
  - Python SDK example
  - Error responses and rate limits

- docs/RBAC.md: RBAC system documentation
  - Two-level access control explained
  - Built-in roles (admin, portfolio-agent, authenticated-user)
  - Authentik configuration guide
  - Scope mapping examples for roles/permissions
  - Troubleshooting guide

JWT Integration:
- Add 'roles' field to JwtClaims struct
- Wire roles from Authentik JWT to RBAC Claims
- API key users get 'admin' role by default

Tests:
- Add test_to_rbac_claims_with_roles
- Verify roles extraction from JWT
- 670 tests passing
This commit is contained in:
2026-09-01 09:44:52 -07:00
parent 3dcf974941
commit ff3e48504c
4 changed files with 907 additions and 6 deletions
+35 -6
View File
@@ -123,6 +123,7 @@ fn validate_apikey(req: &HttpRequest, state: &AppState) -> Result<(JwtClaims, St
nbf: None,
permissions: Some(vec!["*".to_string()]),
groups: None,
roles: Some(vec!["admin".to_string()]), // API key gets admin role
};
Ok((claims, "apikey".to_string()))
@@ -150,6 +151,7 @@ fn extract_rate_limit_key(claims: &JwtClaims) -> String {
/// Convert JWT claims to RBAC claims for AccessGuard
fn to_rbac_claims(jwt: &JwtClaims) -> RbacClaims {
RbacClaims::new(&jwt.sub)
.with_roles(jwt.roles.clone().unwrap_or_default().iter().map(|s| s.as_str()).collect())
.with_groups(jwt.groups.clone().unwrap_or_default().iter().map(|s| s.as_str()).collect())
.with_permissions(jwt.permissions.clone().unwrap_or_default().iter().map(|s| s.as_str()).collect())
}
@@ -1519,6 +1521,28 @@ pub async fn vault_file_handler(
mod tests {
use super::*;
#[test]
fn test_to_rbac_claims_with_roles() {
let jwt = JwtClaims {
sub: "alice".to_string(),
iss: "authentik".to_string(),
aud: "memory".to_string(),
exp: i64::MAX,
iat: 0,
nbf: None,
permissions: Some(vec!["memory:read".to_string()]),
groups: Some(vec!["engineering".to_string()]),
roles: Some(vec!["authenticated-user".to_string(), "homelab-team".to_string()]),
};
let rbac = to_rbac_claims(&jwt);
assert_eq!(rbac.sub, "alice");
assert!(rbac.has_role("authenticated-user"));
assert!(rbac.has_role("homelab-team"));
assert!(!rbac.has_role("admin"));
}
#[test]
fn test_to_rbac_claims_basic() {
let jwt = JwtClaims {
@@ -1530,6 +1554,7 @@ mod tests {
nbf: None,
permissions: Some(vec!["memory:read".to_string(), "memory:write".to_string()]),
groups: Some(vec!["engineering".to_string(), "ml-team".to_string()]),
roles: Some(vec!["authenticated-user".to_string()]),
};
let rbac = to_rbac_claims(&jwt);
@@ -1552,6 +1577,7 @@ mod tests {
nbf: None,
permissions: None,
groups: None,
roles: None,
};
let rbac = to_rbac_claims(&jwt);
@@ -1631,7 +1657,7 @@ mod tests {
let guard = AccessGuard::new(Arc::new(builtin_role_provider()));
// Admin JWT
// Admin JWT with roles from Authentik
let jwt = JwtClaims {
sub: "admin-user".to_string(),
iss: "test".to_string(),
@@ -1641,8 +1667,9 @@ mod tests {
nbf: None,
permissions: Some(vec!["*".to_string()]),
groups: None,
roles: Some(vec!["admin".to_string()]),
};
let rbac_claims = to_rbac_claims(&jwt).with_roles(vec!["admin"]);
let rbac_claims = to_rbac_claims(&jwt);
// Admin can access any project
let project = ResourceMeta::new("secret-project", ResourceType::Project, "secret-project");
@@ -1657,7 +1684,7 @@ mod tests {
let guard = AccessGuard::new(Arc::new(builtin_role_provider()));
// Portfolio agent JWT
// Portfolio agent JWT with roles from Authentik
let jwt = JwtClaims {
sub: "visitor-123".to_string(),
iss: "test".to_string(),
@@ -1667,8 +1694,9 @@ mod tests {
nbf: None,
permissions: Some(vec!["memory:read".to_string()]),
groups: None,
roles: Some(vec!["portfolio-agent".to_string()]),
};
let rbac_claims = to_rbac_claims(&jwt).with_roles(vec!["portfolio-agent"]);
let rbac_claims = to_rbac_claims(&jwt);
// Can read public wiki in allowed project
let public_wiki = ResourceMeta::wiki("doc-1", "homelab")
@@ -1692,7 +1720,7 @@ mod tests {
let guard = AccessGuard::new(Arc::new(builtin_role_provider()));
// JWT with no roles
// JWT with no roles (anonymous user)
let jwt = JwtClaims {
sub: "anonymous".to_string(),
iss: "test".to_string(),
@@ -1702,8 +1730,9 @@ mod tests {
nbf: None,
permissions: None,
groups: None,
roles: None, // No roles assigned
};
let rbac_claims = to_rbac_claims(&jwt); // No roles
let rbac_claims = to_rbac_claims(&jwt);
// Cannot read anything without a role
let wiki = ResourceMeta::wiki("doc", "homelab")
+2
View File
@@ -17,6 +17,8 @@ pub struct JwtClaims {
pub nbf: Option<i64>,
pub permissions: Option<Vec<String>>,
pub groups: Option<Vec<String>>,
/// Roles from Authentik (for RBAC)
pub roles: Option<Vec<String>>,
}
/// JWKS (JSON Web Key Set) response from Authentik