diff --git a/Cargo.lock b/Cargo.lock index ef5a27f..172f3ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -90,6 +90,7 @@ name = "auth-kit" version = "0.1.0" dependencies = [ "axum", + "axum-extra", "base64", "common-telemetry", "dashmap", @@ -183,6 +184,28 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fef252edff26ddba56bbcdf2ee3307b8129acb86f5749b68990c168a6fcc9c76" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "backon" version = "1.6.0" @@ -390,6 +413,17 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.9.4" diff --git a/docs/TEMP.md b/docs/TEMP.md index 05b6f3c..384eb2a 100644 --- a/docs/TEMP.md +++ b/docs/TEMP.md @@ -37,8 +37,8 @@ "code": 0, "message": "Success", "data": { - "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImxvY2FsLWRldiJ9.eyJzdWIiOiI3YTgzYmJjMC0yZjA1LTQwMDItOGFmZC0yYWI1M2RkZDMxNWIiLCJ0ZW5hbnRfaWQiOiI0ZDc3OTQxNC1kYTA0LTQ5YzMtYjM0Mi1kYWJmOTNiNmExMTkiLCJleHAiOjE3NzAwOTc2NzYsImlhdCI6MTc3MDA5Njc3NiwiaXNzIjoiaWFtLXNlcnZpY2UiLCJyb2xlcyI6WyJBZG1pbiIsIm1hbmFnZXIiXSwicGVybWlzc2lvbnMiOlsiY21zOmFydGljbGU6Y3JlYXRlIiwiY21zOmFydGljbGU6ZWRpdCIsImNtczphcnRpY2xlOnB1Ymxpc2giLCJjbXM6Y2F0ZWdvcnk6bWFuYWdlIiwiY21zOm1lZGlhOm1hbmFnZSIsImNtczpzZXR0aW5nczptYW5hZ2UiLCJpYW06Y2xpZW50OnJlYWQiLCJpYW06Y2xpZW50OndyaXRlIiwicm9sZTpyZWFkIiwicm9sZTp3cml0ZSIsInRlbmFudDpyZWFkIiwidGVuYW50OndyaXRlIiwidXNlcjpwYXNzd29yZDpyZXNldDphbnkiLCJ1c2VyOnJlYWQiLCJ1c2VyOndyaXRlIl0sImFwcHMiOlsiY21zIl0sImFwcHNfdmVyc2lvbiI6MX0.Tt7fEQj8wtS5XzJ-GuwRF5yiOrtGaRr9P_V5RqNZagLXj0eRikf9U4oNkFS2uy8Pp75Ks4jL816DzeLXQsXZJfWEtvlTp0QmwyOhYIN1p-yyGS9Pitl0gb7wobjStGDyMSD-ctbHgEsU41qLrQd6ZQkpFl2IpaCSfCN0JZCpc7_3BeI6YLwAw_K4-TFF_1OTNRPm4sT3RnEZYOHXm6EcOUk-MsDBy1itADCdEEUdCSoslK6FGHIpbhkgA56Z7Qy5909BxXW34I21c2rZX-R_iB9q_eKzWd0GqBMIZi33ITbRy4F7_CtCQSwFNUt6-lvVvzXYLHsVQchcpOdYtj3h6w", - "refresh_token": "7483624e8942aee112e62b2b58ec902fb01731dd7b098b4af6001e350b2303f0", + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImRlZmF1bHQifQ.eyJzdWIiOiI3YTgzYmJjMC0yZjA1LTQwMDItOGFmZC0yYWI1M2RkZDMxNWIiLCJ0ZW5hbnRfaWQiOiI0ZDc3OTQxNC1kYTA0LTQ5YzMtYjM0Mi1kYWJmOTNiNmExMTkiLCJleHAiOjE3NzA3NzU0NDYsImlhdCI6MTc3MDc3NDU0NiwiaXNzIjoiaWFtLXNlcnZpY2UiLCJyb2xlcyI6WyJBZG1pbiIsIm1hbmFnZXIiXSwicGVybWlzc2lvbnMiOlsiY21zOmFydGljbGU6Y3JlYXRlIiwiY21zOmFydGljbGU6ZWRpdCIsImNtczphcnRpY2xlOnB1Ymxpc2giLCJjbXM6Y2F0ZWdvcnk6bWFuYWdlIiwiY21zOm1lZGlhOm1hbmFnZSIsImNtczpzZXR0aW5nczptYW5hZ2UiLCJpYW06Y2xpZW50OnJlYWQiLCJpYW06Y2xpZW50OndyaXRlIiwicm9sZTpyZWFkIiwicm9sZTp3cml0ZSIsInRlbmFudDpyZWFkIiwidGVuYW50OndyaXRlIiwidXNlcjpwYXNzd29yZDpyZXNldDphbnkiLCJ1c2VyOnJlYWQiLCJ1c2VyOndyaXRlIl0sImFwcHMiOlsiY21zIl0sImFwcHNfdmVyc2lvbiI6MX0.yPCz0CgX4qc1AqFM_yi-IIthVRBzYykFAIdJQmB0O6EEWSMutG3WtW2L9lvvBEajmJuje-PIxj-UwpS6tInzUR0H7BKdqOwmao9CKJkaOjp6aSfOZUxQ90kaR1PnJtjmkI8r6rorvWtTyoLIMzwbHisXjMoYxZ5-JTrOmXdYs_MT50cn-tzb7eV_eM7g77J82-vxTVQq0olKA7rLpOFw2B4hHOoNjniL8Tas8zupHakVZzW7G1Q25tveqJtck9rDfEm2KpswKC_Kh4-kk1E5M1rESbpzM0h9VENTd2EPgxvOcIjPtczkMp89qlrovQqZteCCyDDSWnfGPWGsVrm7tQ", + "refresh_token": "56923a401cf9bbf08f988dfff05b28aabbff0814a02f7760b5fbf34f56df8ed8", "token_type": "Bearer", "expires_in": 900 } @@ -46,7 +46,7 @@ ``` `user1@example.com` -`user1secret` +`user5secret` ```json { diff --git a/src/application/services/authorization.rs b/src/application/services/authorization.rs index 3fe6200..2079154 100644 --- a/src/application/services/authorization.rs +++ b/src/application/services/authorization.rs @@ -4,6 +4,8 @@ use sqlx::PgPool; use tracing::instrument; use uuid::Uuid; +use crate::models::{PermissionExpr, PermissionExprItem}; + #[derive(Clone)] pub struct AuthorizationService { pool: PgPool, @@ -88,6 +90,17 @@ impl AuthorizationService { } } + pub async fn check_permission_expr( + &self, + tenant_id: Uuid, + user_id: Uuid, + expr: &PermissionExpr, + ) -> Result { + validate_expr_depth(expr, 32)?; + let permissions = self.list_permissions_for_user(tenant_id, user_id).await?; + Ok(evaluate_expr(&permissions, expr)) + } + #[instrument(skip(self))] pub async fn list_platform_permissions_for_user( &self, @@ -122,3 +135,45 @@ impl AuthorizationService { } } } + +fn validate_expr_depth(expr: &PermissionExpr, max_depth: usize) -> Result<(), AppError> { + fn walk(expr: &PermissionExpr, depth: usize, max_depth: usize) -> Result<(), AppError> { + if depth > max_depth { + return Err(AppError::BadRequest("permission expr too deep".into())); + } + let items: &[PermissionExprItem] = match expr { + PermissionExpr::Any(x) => &x.any, + PermissionExpr::All(x) => &x.all, + }; + for item in items { + if let PermissionExprItem::Expr(e) = item { + walk(e, depth + 1, max_depth)?; + } + } + Ok(()) + } + + walk(expr, 1, max_depth) +} + +fn evaluate_expr(user_permissions: &[String], expr: &PermissionExpr) -> bool { + let items: &[PermissionExprItem] = match expr { + PermissionExpr::Any(x) => &x.any, + PermissionExpr::All(x) => &x.all, + }; + + match expr { + PermissionExpr::Any(_) => items.iter().any(|item| match item { + PermissionExprItem::Permission(required) => user_permissions + .iter() + .any(|granted| matches_permission(granted.as_str(), required.as_str())), + PermissionExprItem::Expr(e) => evaluate_expr(user_permissions, e), + }), + PermissionExpr::All(_) => items.iter().all(|item| match item { + PermissionExprItem::Permission(required) => user_permissions + .iter() + .any(|granted| matches_permission(granted.as_str(), required.as_str())), + PermissionExprItem::Expr(e) => evaluate_expr(user_permissions, e), + }), + } +} diff --git a/src/application/services/client.rs b/src/application/services/client.rs index e65f10b..86afe48 100644 --- a/src/application/services/client.rs +++ b/src/application/services/client.rs @@ -252,6 +252,13 @@ impl ClientService { let requested_path = requested.path().to_string(); let requested_scheme = requested.scheme().to_string(); + if arr.is_empty() + && cfg!(debug_assertions) + && (requested_host == "localhost" || requested_host == "127.0.0.1") + { + return Ok(normalized); + } + let allowed = arr.iter().filter_map(|x| x.as_str()).any(|raw_allowed| { let allowed_norm = match self.normalize_redirect_uri(raw_allowed) { Ok(v) => v, @@ -265,7 +272,14 @@ impl ClientService { let host = allowed_url.host_str().unwrap_or_default(); let port = allowed_url.port_or_known_default().unwrap_or(0); - if allowed_url.scheme() != requested_scheme + let scheme_ok = allowed_url.scheme() == requested_scheme + || (cfg!(debug_assertions) + && (requested_host == "localhost" || requested_host == "127.0.0.1") + && host == requested_host + && ((allowed_url.scheme() == "https" && requested_scheme == "http") + || (allowed_url.scheme() == "http" && requested_scheme == "https"))); + + if !scheme_ok || host != requested_host || port != requested_port || allowed_url.path() != requested_path @@ -376,4 +390,3 @@ impl ClientService { .collect()) } } - diff --git a/src/models.rs b/src/models.rs index a0e73d7..e3f52d7 100644 --- a/src/models.rs +++ b/src/models.rs @@ -361,7 +361,6 @@ pub struct RefreshTokenRequest { } #[derive(Debug, Deserialize, ToSchema, IntoParams)] -#[serde(rename_all = "camelCase")] pub struct LoginCodeRequest { #[schema(default = "", example = "user@example.com")] #[serde(default)] @@ -371,17 +370,18 @@ pub struct LoginCodeRequest { pub password: String, #[schema(default = "", example = "cms")] #[serde(default)] + #[serde(alias = "clientId")] pub client_id: String, #[schema( default = "", example = "https://cms-api.example.com/auth/callback?next=https%3A%2F%2Fcms.example.com%2F" )] #[serde(default)] + #[serde(alias = "redirectUri")] pub redirect_uri: String, } #[derive(Debug, Serialize, ToSchema, IntoParams)] -#[serde(rename_all = "camelCase")] pub struct LoginCodeResponse { #[schema( default = "", @@ -395,21 +395,21 @@ pub struct LoginCodeResponse { } #[derive(Debug, Deserialize, ToSchema, IntoParams)] -#[serde(rename_all = "camelCase")] pub struct Code2TokenRequest { #[schema(default = "", example = "one_time_code_jwt")] #[serde(default)] pub code: String, #[schema(default = "", example = "cms")] #[serde(default)] + #[serde(alias = "clientId")] pub client_id: String, #[schema(default = "", example = "client_secret")] #[serde(default)] + #[serde(alias = "clientSecret")] pub client_secret: String, } #[derive(Debug, Serialize, ToSchema, IntoParams)] -#[serde(rename_all = "camelCase")] pub struct Code2TokenResponse { #[schema(default = "", example = "access_token")] #[serde(default)] @@ -432,20 +432,20 @@ pub struct Code2TokenResponse { } #[derive(Debug, Deserialize, ToSchema, IntoParams)] -#[serde(rename_all = "camelCase")] pub struct CreateClientRequest { #[schema(default = "", example = "cms")] #[serde(default)] + #[serde(alias = "clientId")] pub client_id: String, #[serde(default)] pub name: Option, #[schema(example = "[\"https://cms.example.com/auth/callback\"]")] #[serde(default)] + #[serde(alias = "redirectUris")] pub redirect_uris: Option>, } #[derive(Debug, Serialize, ToSchema, IntoParams)] -#[serde(rename_all = "camelCase")] pub struct CreateClientResponse { #[schema(default = "", example = "cms")] #[serde(default)] @@ -456,7 +456,6 @@ pub struct CreateClientResponse { } #[derive(Debug, Serialize, ToSchema, IntoParams)] -#[serde(rename_all = "camelCase")] pub struct RotateClientSecretResponse { #[schema(default = "", example = "cms")] #[serde(default)] @@ -467,7 +466,6 @@ pub struct RotateClientSecretResponse { } #[derive(Debug, Serialize, ToSchema, IntoParams)] -#[serde(rename_all = "camelCase")] pub struct ClientSummary { #[schema(default = "", example = "cms")] #[serde(default)] @@ -476,6 +474,7 @@ pub struct ClientSummary { pub name: Option, #[schema(example = "[\"https://cms.example.com/auth/callback\"]")] #[serde(default)] + #[serde(alias = "redirectUris")] pub redirect_uris: Vec, #[schema(default = "", example = "2026-02-02T12:00:00Z")] #[serde(default)] @@ -486,10 +485,10 @@ pub struct ClientSummary { } #[derive(Debug, Deserialize, ToSchema, IntoParams)] -#[serde(rename_all = "camelCase")] pub struct UpdateClientRedirectUrisRequest { #[schema(example = "[\"https://cms.example.com/auth/callback\"]")] #[serde(default)] + #[serde(alias = "redirectUris")] pub redirect_uris: Vec, } @@ -552,3 +551,39 @@ pub struct AuthorizationCheckResponse { #[serde(default)] pub allowed: bool, } + +#[derive(Debug, Deserialize, Serialize, ToSchema, IntoParams)] +pub struct AuthorizationExprCheckRequest { + pub expr: PermissionExpr, +} + +#[derive(Debug, Serialize, ToSchema, IntoParams)] +pub struct AuthorizationExprCheckResponse { + pub allowed: bool, +} + +#[derive(Debug, Deserialize, Serialize, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct AnyExpr { + pub any: Vec, +} + +#[derive(Debug, Deserialize, Serialize, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct AllExpr { + pub all: Vec, +} + +#[derive(Debug, Deserialize, Serialize, ToSchema)] +#[serde(untagged)] +pub enum PermissionExpr { + Any(AnyExpr), + All(AllExpr), +} + +#[derive(Debug, Deserialize, Serialize, ToSchema)] +#[serde(untagged)] +pub enum PermissionExprItem { + Permission(String), + Expr(Box), +} diff --git a/src/presentation/http/api.rs b/src/presentation/http/api.rs index abd9e7d..943d224 100644 --- a/src/presentation/http/api.rs +++ b/src/presentation/http/api.rs @@ -26,6 +26,7 @@ pub fn routes() -> Vec<(&'static str, &'static str)> { ("POST", "/internal/auth/code2token"), ("GET", "/me/permissions"), ("POST", "/authorize/check"), + ("POST", "/authorize/check-expr"), ("GET", "/users"), ("GET", "/users/{id}"), ("PATCH", "/users/{id}"), @@ -144,6 +145,10 @@ pub fn build_app(state: AppState) -> Router { "/authorize/check", post(authorization::authorization_check_handler), ) + .route( + "/authorize/check-expr", + post(authorization::authorization_check_expr_handler), + ) .route("/users", get(user::list_users_handler)) .route( "/users/me/password/reset", diff --git a/src/presentation/http/handlers/authorization.rs b/src/presentation/http/handlers/authorization.rs index a34e0e7..d58632d 100644 --- a/src/presentation/http/handlers/authorization.rs +++ b/src/presentation/http/handlers/authorization.rs @@ -1,6 +1,9 @@ use crate::middleware::TenantId; use crate::middleware::auth::AuthContext; -use crate::models::{AuthorizationCheckRequest, AuthorizationCheckResponse}; +use crate::models::{ + AuthorizationCheckRequest, AuthorizationCheckResponse, AuthorizationExprCheckRequest, + AuthorizationExprCheckResponse, +}; use crate::presentation::http::state::AppState; use axum::{Json, extract::State}; use common_telemetry::{AppError, AppResponse}; @@ -89,3 +92,45 @@ pub async fn authorization_check_handler( Ok(AppResponse::ok(AuthorizationCheckResponse { allowed })) } + +#[utoipa::path( + post, + path = "/authorize/check-expr", + tag = "Policy", + security( + ("bearer_auth" = []) + ), + request_body = AuthorizationExprCheckRequest, + responses( + (status = 200, description = "鉴权校验结果", body = AuthorizationExprCheckResponse), + (status = 400, description = "表达式不合法"), + (status = 401, description = "未认证"), + (status = 403, description = "租户不匹配") + ), + params( + ("Authorization" = String, Header, description = "Bearer (访问令牌)"), + ("X-Tenant-ID" = String, Header, description = "租户 UUID(可选,若提供需与 Token 中 tenant_id 一致)") + ) +)] +#[instrument(skip(state, body))] +pub async fn authorization_check_expr_handler( + TenantId(tenant_id): TenantId, + State(state): State, + AuthContext { + tenant_id: auth_tenant_id, + user_id, + .. + }: AuthContext, + Json(body): Json, +) -> Result, AppError> { + if auth_tenant_id != tenant_id { + return Err(AppError::PermissionDenied("tenant:mismatch".into())); + } + + let allowed = state + .authorization_service + .check_permission_expr(tenant_id, user_id, &body.expr) + .await?; + + Ok(AppResponse::ok(AuthorizationExprCheckResponse { allowed })) +} diff --git a/tests/authorization_expr.rs b/tests/authorization_expr.rs new file mode 100644 index 0000000..00964bf --- /dev/null +++ b/tests/authorization_expr.rs @@ -0,0 +1,27 @@ +use iam_service::models::{AuthorizationExprCheckRequest, PermissionExpr}; + +#[test] +fn deserialize_any_expr() { + let json = r#"{ "expr": { "any": ["cms:article:edit", "cms:article:create"] } }"#; + let parsed: AuthorizationExprCheckRequest = serde_json::from_str(json).unwrap(); + match parsed.expr { + PermissionExpr::Any(x) => { + assert_eq!(x.any.len(), 2); + } + _ => panic!("expected any"), + } +} + +#[test] +fn deserialize_nested_expr() { + let json = + r#"{ "expr": { "all": ["cms:article:edit", { "any": ["cms:article:create", "cms:article:publish"] }] } }"#; + let parsed: AuthorizationExprCheckRequest = serde_json::from_str(json).unwrap(); + match parsed.expr { + PermissionExpr::All(x) => { + assert_eq!(x.all.len(), 2); + } + _ => panic!("expected all"), + } +} + diff --git a/tests/code2token_modes.rs b/tests/code2token_modes.rs index e714b61..a0220d5 100644 --- a/tests/code2token_modes.rs +++ b/tests/code2token_modes.rs @@ -1,14 +1,15 @@ use axum::body::Body; use axum::http::{Request, StatusCode}; -use iam_service::presentation::http::api; -use iam_service::presentation::http::state::AppState; -use iam_service::infrastructure::repositories::tenant_config_repo::TenantConfigRepoPg; -use iam_service::models::CreateTenantRequest; -use iam_service::models::CreateUserRequest; use iam_service::application::services::{ AppService, AuthService, AuthorizationService, ClientService, PermissionService, RoleService, TenantService, UserService, }; +use iam_service::constants::CANONICAL_BASE; +use iam_service::infrastructure::repositories::tenant_config_repo::TenantConfigRepoPg; +use iam_service::models::CreateTenantRequest; +use iam_service::models::CreateUserRequest; +use iam_service::presentation::http::api; +use iam_service::presentation::http::state::AppState; use redis::aio::ConnectionManager; use sqlx::PgPool; use tower::ServiceExt; @@ -106,7 +107,7 @@ async fn code2token_modes_requirements() -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box