diff --git a/bindings/csharp/FusionFramework/Middleware.cs b/bindings/csharp/FusionFramework/Middleware.cs index 30ce04b..4a0001a 100644 --- a/bindings/csharp/FusionFramework/Middleware.cs +++ b/bindings/csharp/FusionFramework/Middleware.cs @@ -4,6 +4,9 @@ namespace FusionFramework; +/// Custom route permission check — return false to deny with HTTP 403. +public delegate bool FusionPermission(FusionRequest request); + public delegate object? FusionMiddleware(FusionRequest request, Func callNext); public static class Middleware @@ -186,6 +189,24 @@ public static FusionMiddleware RequireRoles( public static FusionMiddleware RequireRoles(params string[] roles) => RequireRoles((IEnumerable)roles); + /// Route middleware: run custom permission checks; any failure → 403. + public static FusionMiddleware RequirePermissions(params FusionPermission[] checks) => + RequirePermissions((IEnumerable)checks); + + public static FusionMiddleware RequirePermissions(IEnumerable checks) + { + var list = checks.ToList(); + return (request, callNext) => + { + foreach (var check in list) + { + if (!check(request)) + return Error(403, "Forbidden"); + } + return callNext(request); + }; + } + /// Common security response headers. public static FusionMiddleware SecurityHeaders( string contentTypeOptions = "nosniff", diff --git a/bindings/csharp/FusionFramework/README.md b/bindings/csharp/FusionFramework/README.md index 353aee3..fae1fc4 100644 --- a/bindings/csharp/FusionFramework/README.md +++ b/bindings/csharp/FusionFramework/README.md @@ -114,7 +114,7 @@ Parameters appear in Swagger OpenAPI docs. ```csharp MIDDLEWARE.Add(Middleware.BearerJwt()); -Route.Register(typeof(AdminModule), "/api/admin", roles: new[] { "admin" }); +Route.Register(typeof(AdminModule), "/api/admin", permissions: new[] { AdminChecks.IsAdmin }); ``` ## License diff --git a/bindings/csharp/FusionFramework/Route.cs b/bindings/csharp/FusionFramework/Route.cs index d372c1b..82ebd62 100644 --- a/bindings/csharp/FusionFramework/Route.cs +++ b/bindings/csharp/FusionFramework/Route.cs @@ -14,9 +14,7 @@ public sealed class RouteAttribute : Attribute public string? Title { get; set; } public string? Version { get; set; } public bool Deprecated { get; set; } - public string[]? Roles { get; set; } - public string RoleClaim { get; set; } = "roles"; - public string RoleStateKey { get; set; } = "jwt"; + public Type[]? PermissionTypes { get; set; } public RouteAttribute(string path) => Path = path; } @@ -44,6 +42,7 @@ internal sealed class RouteEntry public string? Desc { get; init; } public string? Title { get; init; } public bool Deprecated { get; init; } + public bool RequiresPermissions { get; init; } public List Slots { get; init; } = new(); } @@ -149,10 +148,8 @@ public static Type Register( Type apiClass, string path, IEnumerable? middleware = null, - IEnumerable? roles = null, + IEnumerable? permissions = null, string? version = null, - string roleClaim = "roles", - string roleStateKey = "jwt", IEnumerable? tags = null, string? desc = null, string? title = null, @@ -167,12 +164,10 @@ public static Type Register( resolved = $"{v}/{resolved.TrimStart('/')}"; var chain = middleware?.ToList() ?? new List(); - if (roles != null) - { - var roleList = roles.ToList(); - if (roleList.Count > 0) - chain.Add(Middleware.RequireRoles(roleList, roleClaim, roleStateKey)); - } + var permissionChecks = ResolvePermissions(permissions).ToList(); + var requiresPermissions = permissionChecks.Count > 0; + if (requiresPermissions) + chain.Add(Middleware.RequirePermissions(permissionChecks)); var classBasePath = resolved.StartsWith('/') ? resolved : "/" + resolved; var classTags = tags?.ToArray() ?? Array.Empty(); @@ -190,12 +185,41 @@ public static Type Register( Desc = desc, Title = title, Deprecated = deprecated, + RequiresPermissions = requiresPermissions, Slots = BuildSlots(apiClass, classBasePath, classTags, desc, title, deprecated), }); } return apiClass; } + static IEnumerable ResolvePermissions(IEnumerable? permissions) + { + if (permissions is null) yield break; + foreach (var check in permissions) + yield return check; + } + + static IEnumerable ResolvePermissionTypes(Type[]? types) + { + if (types is null || types.Length == 0) yield break; + foreach (var type in types) + { + var method = type.GetMethod( + "Check", + BindingFlags.Public | BindingFlags.Static, + binder: null, + types: new[] { typeof(FusionRequest) }, + modifiers: null); + if (method is null) + { + throw new InvalidOperationException( + $"{type.Name} must define public static bool Check(FusionRequest request)"); + } + yield return request => (bool)(method.Invoke(null, new object[] { request }) + ?? throw new InvalidOperationException($"{type.Name}.Check returned null")); + } + } + public static Type Register() where T : FusionBaseApi { var attr = typeof(T).GetCustomAttribute() @@ -203,10 +227,8 @@ public static Type Register() where T : FusionBaseApi return Register( typeof(T), attr.Path, - roles: attr.Roles, + permissions: ResolvePermissionTypes(attr.PermissionTypes), version: attr.Version, - roleClaim: attr.RoleClaim, - roleStateKey: attr.RoleStateKey, tags: attr.Tags, desc: attr.Desc, title: attr.Title, @@ -226,10 +248,8 @@ public static void RegisterAll(Assembly assembly) Register( type, attr.Path, - roles: attr.Roles, + permissions: ResolvePermissionTypes(attr.PermissionTypes), version: attr.Version, - roleClaim: attr.RoleClaim, - roleStateKey: attr.RoleStateKey, tags: attr.Tags, desc: attr.Desc, title: attr.Title, diff --git a/bindings/csharp/FusionFramework/Swagger.cs b/bindings/csharp/FusionFramework/Swagger.cs index d395e2e..394daea 100644 --- a/bindings/csharp/FusionFramework/Swagger.cs +++ b/bindings/csharp/FusionFramework/Swagger.cs @@ -215,10 +215,31 @@ static JsonObject BuildOpenApi(SwaggerConfig swagger, string? version = null) if (swagger.AuthGlobal.Count > 0) spec["security"] = swagger.AuthGlobal.DeepClone(); - FillPaths((JsonObject)spec["paths"]!, version); + var anyPermissions = FillPaths((JsonObject)spec["paths"]!, version); + if (anyPermissions) + { + if (spec["components"] is not JsonObject components) + { + components = new JsonObject(); + spec["components"] = components; + } + if (components["securitySchemes"] is not JsonObject schemes) + { + schemes = new JsonObject(); + components["securitySchemes"] = schemes; + } + schemes["FusionPermissions"] = new JsonObject + { + ["type"] = "apiKey", + ["in"] = "header", + ["name"] = "Authorization", + ["description"] = "Route requires custom permission checks to pass", + }; + } return spec; } + /// Build a minimal OpenAPI document for unit tests without a live Swagger config. internal static JsonObject CreateTestSpec(string? version = null) { var swagger = new SwaggerConfig @@ -235,12 +256,17 @@ internal static JsonObject CreateTestSpec(string? version = null) return BuildOpenApi(swagger, version); } - static void FillPaths(JsonObject paths, string? versionFilter) + /// Fill OpenAPI path operations; returns true if any route requires permissions. + static bool FillPaths(JsonObject paths, string? versionFilter) { + const string permissionsScheme = "FusionPermissions"; + var anyPermissions = false; + foreach (var entry in Route.Snapshot()) { if (!MatchesVersion(entry.Version, versionFilter)) continue; if (typeof(FusionBaseTemplate).IsAssignableFrom(entry.ApiClass)) continue; + if (entry.RequiresPermissions) anyPermissions = true; foreach (var slot in entry.Slots) { @@ -354,9 +380,26 @@ static void FillPaths(JsonObject paths, string? versionFilter) }; } + if (entry.RequiresPermissions) + { + operation["security"] = new JsonArray + { + new JsonObject { [permissionsScheme] = new JsonArray() }, + }; + if (operation["responses"] is JsonObject responses) + { + responses["403"] = new JsonObject + { + ["description"] = "Forbidden — permission check failed", + }; + } + } + methods[methodLower] = operation; } } + + return anyPermissions; } static bool MatchesVersion(string? routeVersion, string? filter) diff --git a/crates/fusion-node/index.d.ts b/crates/fusion-node/index.d.ts index 9a7957b..4dddf75 100644 --- a/crates/fusion-node/index.d.ts +++ b/crates/fusion-node/index.d.ts @@ -83,9 +83,7 @@ export type RouteOptions = { version?: string deprecated?: boolean middleware?: FusionMiddleware[] - roles?: string[] - roleClaim?: string - roleStateKey?: string + permissions?: Array<(request: FusionRequest) => boolean> } export function router(path: string, options?: RouteOptions): (ApiClass: T) => T @@ -98,6 +96,10 @@ export function bearerJwt(options?: { verify?: (token: string) => Record | null }): FusionMiddleware +export function requirePermissions( + ...checks: Array<(request: FusionRequest) => boolean> +): FusionMiddleware + export function requireRoles(...roles: string[]): FusionMiddleware export function requireRoles(options: { roles: string[] diff --git a/crates/fusion-node/index.js b/crates/fusion-node/index.js index 71594c3..018f25a 100644 --- a/crates/fusion-node/index.js +++ b/crates/fusion-node/index.js @@ -609,6 +609,17 @@ async function runMiddlewareChain(request, middlewares, handler) { return dispatch(0, request) } +function requirePermissions(...checks) { + return (request, callNext) => { + for (const check of checks) { + if (!check(request)) { + return { status: 403, body: { detail: 'Forbidden' } } + } + } + return callNext(request) + } +} + function requireRoles(...rolesOrOptions) { let roles = rolesOrOptions let claim = 'roles' @@ -692,14 +703,9 @@ function router(routePath, options = {}) { ApiClass.__fusion_path_template__ = routePath const routeMiddleware = Array.isArray(options.middleware) ? [...options.middleware] : [] - if (Array.isArray(options.roles) && options.roles.length) { - routeMiddleware.push( - requireRoles({ - roles: options.roles, - claim: options.roleClaim || 'roles', - stateKey: options.roleStateKey || 'jwt', - }), - ) + const permissionChecks = Array.isArray(options.permissions) ? options.permissions : [] + if (permissionChecks.length) { + routeMiddleware.push(requirePermissions(...permissionChecks)) } const classSwagger = { @@ -716,6 +722,7 @@ function router(routePath, options = {}) { middleware: routeMiddleware, swagger: classSwagger, version_prefix: v, + requiresPermissions: permissionChecks.length > 0, slots: collectRouteSlots(ApiClass, resolved, classSwagger), }) return ApiClass @@ -1004,6 +1011,8 @@ function applySwaggerOpenApi(openapi, swagger) { return openapi } +const OPENAPI_PERMISSIONS_SCHEME = 'FusionPermissions' + function isTemplateClass(ApiClass) { let current = ApiClass while (current && current !== Function.prototype) { @@ -1021,10 +1030,13 @@ function fillOpenApiPaths(openapi, versionFilter = null) { .map((seg) => seg.slice(1, -1)) } + let anyPermissions = false + for (const item of registry) { if (!routeMatchesVersion(item, versionFilter)) continue - const { ApiClass, swagger: routeSwagger } = item + const { ApiClass, swagger: routeSwagger, requiresPermissions } = item if (isTemplateClass(ApiClass)) continue + if (requiresPermissions) anyPermissions = true const slots = item.slots || [] for (const slot of slots) { @@ -1050,10 +1062,27 @@ function fillOpenApiPaths(openapi, versionFilter = null) { deprecated: !!routeSwaggerEntry?.deprecated, operationId: `${ApiClass.name}_${slot.handlerMethod}`, parameters: params, - responses: { 200: { description: 'OK' } }, + responses: { + 200: { description: 'OK' }, + ...(requiresPermissions ? { 403: { description: 'Forbidden — permission check failed' } } : {}), + }, + ...(requiresPermissions ? { security: [{ [OPENAPI_PERMISSIONS_SCHEME]: [] }] } : {}), } } } + + if (anyPermissions) { + openapi.components = asObject(openapi.components) + openapi.components.securitySchemes = { + ...asObject(openapi.components.securitySchemes), + [OPENAPI_PERMISSIONS_SCHEME]: { + type: 'apiKey', + in: 'header', + name: 'Authorization', + description: 'Route requires custom permission checks to pass', + }, + } + } return openapi } @@ -1492,6 +1521,7 @@ module.exports = { run, bearerJwt, requireRoles, + requirePermissions, frameworkHeaders, securityHeaders, cors, diff --git a/crates/fusion-py/python/fusion_framework/__init__.py b/crates/fusion-py/python/fusion_framework/__init__.py index 6f807f2..62ed66b 100644 --- a/crates/fusion-py/python/fusion_framework/__init__.py +++ b/crates/fusion-py/python/fusion_framework/__init__.py @@ -8,6 +8,7 @@ cors, framework_headers, request_id, + require_permissions, require_roles, security_headers, use, @@ -26,6 +27,7 @@ "cors", "framework_headers", "request_id", + "require_permissions", "require_roles", "security_headers", "use", diff --git a/crates/fusion-py/python/fusion_framework/middleware.py b/crates/fusion-py/python/fusion_framework/middleware.py index 810d17a..65222f3 100644 --- a/crates/fusion-py/python/fusion_framework/middleware.py +++ b/crates/fusion-py/python/fusion_framework/middleware.py @@ -113,6 +113,22 @@ async def call_next(next_req: RequestDict) -> Any: return await dispatch(0, request) +def require_permissions(*checks: Callable[[RequestDict], Any]) -> Middleware: + """Route middleware: run custom permission checks; any falsy result → 403. + + Each check receives the request dict (``method``, ``path``, ``headers``, + ``body``, ``state``, …). Omit checks to allow any caller (default). + """ + + def middleware(request: RequestDict, call_next: Callable[[RequestDict], Any]) -> Any: + for check in checks: + if not check(request): + return {"status": 403, "body": {"detail": "Forbidden"}} + return call_next(request) + + return middleware + + def require_roles( *roles: str, claim: str = "roles", diff --git a/crates/fusion-py/python/fusion_framework/route.py b/crates/fusion-py/python/fusion_framework/route.py index 9209b2e..1a3ac11 100644 --- a/crates/fusion-py/python/fusion_framework/route.py +++ b/crates/fusion-py/python/fusion_framework/route.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Callable, Optional, Sequence, Type +from typing import Any, Callable, Optional, Sequence, Type from fusion_framework._fusion import ( HTTP_METHODS, @@ -28,7 +28,7 @@ http_post, http_put, ) -from fusion_framework.middleware import require_roles +from fusion_framework.middleware import require_permissions __all__ = [ "HTTP_METHODS", @@ -63,20 +63,22 @@ def route( version: Optional[str] = None, deprecated: bool = False, middleware: Optional[Sequence] = None, - roles: Optional[Sequence[str]] = None, + permissions: Optional[Sequence[Callable[..., Any]]] = None, ) -> Callable[[Type[FusionBaseApi]], Type[FusionBaseApi]]: """Register a ``FusionBaseApi`` subclass. ``middleware`` — callables ``(request, call_next) -> response | call_next(...)``. - ``roles`` — shorthand that appends a ``require_roles(...)`` route middleware. + ``permissions`` — callables ``(request) -> bool``; all must pass or the route + returns 403. Empty / omitted means allow any caller. ``version`` — API prefix such as ``v1`` (path becomes ``/v1/...``). Each version gets its own OpenAPI spec and appears in the Swagger navbar. """ def decorator(cls: Type[FusionBaseApi]) -> Type[FusionBaseApi]: route_middleware = list(middleware or []) - if roles: - route_middleware.append(require_roles(*roles)) + perm_checks = list(permissions or []) + if perm_checks: + route_middleware.append(require_permissions(*perm_checks)) register_route( path, @@ -87,6 +89,7 @@ def decorator(cls: Type[FusionBaseApi]) -> Type[FusionBaseApi]: version, deprecated, route_middleware, + bool(perm_checks), ) return cls diff --git a/crates/fusion-py/src/api_types.rs b/crates/fusion-py/src/api_types.rs index 8c9febb..3cbff6b 100644 --- a/crates/fusion-py/src/api_types.rs +++ b/crates/fusion-py/src/api_types.rs @@ -273,6 +273,7 @@ struct RegisteredRoute { middleware: Vec>, /// Normalized route version (`v1`), if the `@route(..., version=)` was set. version: Option, + requires_permissions: bool, slots: Vec, } @@ -339,6 +340,7 @@ pub fn register_route( version: Option, deprecated: bool, middleware: Vec>, + requires_permissions: bool, ) -> PyResult { let py = api_cls.py(); @@ -414,6 +416,7 @@ pub fn register_route( api_cls: api_cls.unbind(), middleware, version, + requires_permissions, slots, }); @@ -897,6 +900,7 @@ pub fn openapi_spec() -> serde_json::Value { pub fn openapi_spec_for(version: Option<&str>) -> serde_json::Value { use serde_json::{Map, Value, json}; const OPENAPI_VERSION: &str = "3.0.3"; + const PERMISSIONS_SCHEME: &str = "FusionPermissions"; let routes_guard = match REGISTRY.lock() { Ok(g) => g, @@ -905,6 +909,7 @@ pub fn openapi_spec_for(version: Option<&str>) -> serde_json::Value { let mut tags_set: std::collections::BTreeSet = Default::default(); let mut paths: Map = Map::new(); + let mut any_permissions = false; for r in routes_guard.iter() { if !route_matches_version_filter(r.version.as_deref(), version) { @@ -1029,6 +1034,22 @@ pub fn openapi_spec_for(version: Option<&str>) -> serde_json::Value { } } + if r.requires_permissions { + any_permissions = true; + if let Some(obj) = op.as_object_mut() { + obj.insert( + "security".to_string(), + json!([{ PERMISSIONS_SCHEME: [] }]), + ); + if let Some(resp) = obj.get_mut("responses").and_then(|v| v.as_object_mut()) { + resp.insert( + "403".to_string(), + json!({ "description": "Forbidden — permission check failed" }), + ); + } + } + } + let methods_obj = paths .entry(resolved_path) .or_insert_with(|| Value::Object(Map::new())); @@ -1059,8 +1080,25 @@ pub fn openapi_spec_for(version: Option<&str>) -> serde_json::Value { "info": { "title": "fusion-framework", "version": "0.1.0" }, "paths": paths, }); - if let (Some(t), Some(spec_obj)) = (tags, spec.as_object_mut()) { - spec_obj.insert("tags".to_string(), Value::Array(t)); + if let Some(spec_obj) = spec.as_object_mut() { + if any_permissions { + spec_obj.insert( + "components".to_string(), + json!({ + "securitySchemes": { + PERMISSIONS_SCHEME: { + "type": "apiKey", + "in": "header", + "name": "Authorization", + "description": "Route requires custom permission checks to pass", + } + } + }), + ); + } + if let Some(t) = tags { + spec_obj.insert("tags".to_string(), Value::Array(t)); + } } spec diff --git a/crates/fusion-py/src/lib.rs b/crates/fusion-py/src/lib.rs index a98d45c..800239d 100644 --- a/crates/fusion-py/src/lib.rs +++ b/crates/fusion-py/src/lib.rs @@ -414,7 +414,7 @@ fn py_resolve_route_path(template: &str, class_name: &str) -> String { } #[pyfunction(name = "register_route")] -#[pyo3(signature = (template, api_cls, tags=Vec::new(), desc=None, title=None, version=None, deprecated=false, middleware=Vec::new()))] +#[pyo3(signature = (template, api_cls, tags=Vec::new(), desc=None, title=None, version=None, deprecated=false, middleware=Vec::new(), requires_permissions=false))] fn py_register_route( template: &str, api_cls: Bound<'_, PyType>, @@ -424,6 +424,7 @@ fn py_register_route( version: Option, deprecated: bool, middleware: Vec, + requires_permissions: bool, ) -> PyResult { let py = api_cls.py(); let middleware: Vec> = middleware @@ -432,6 +433,7 @@ fn py_register_route( .collect(); register_route( template, api_cls, tags, desc, title, version, deprecated, middleware, + requires_permissions, ) } diff --git a/examples/middleware_demo.py b/examples/middleware_demo.py index f790070..2a6dd5b 100644 --- a/examples/middleware_demo.py +++ b/examples/middleware_demo.py @@ -1,4 +1,4 @@ -"""Middleware demo: global JWT decode + route role guard. +"""Middleware demo: global JWT decode + route permission guards. Run:: @@ -26,7 +26,23 @@ from fusion_framework.route import route -@route("/api/admin", roles=["admin", "super_admin"]) +def is_admin(request): + jwt = (request.get("state") or {}).get("jwt") or {} + roles = jwt.get("roles") or [] + if isinstance(roles, str): + roles = [roles] + return "admin" in roles or "super_admin" in roles + + +def is_super_admin(request): + jwt = (request.get("state") or {}).get("jwt") or {} + roles = jwt.get("roles") or [] + if isinstance(roles, str): + roles = [roles] + return "super_admin" in roles + + +@route("/api/admin", permissions=[is_admin]) class AdminModule(FusionBaseApi): def get(self): user = self.state.get("jwt", {}) @@ -36,7 +52,7 @@ def get(self): ) -@route("/api/super", roles=["super_admin"]) +@route("/api/super", permissions=[is_super_admin]) class SuperModule(FusionBaseApi): def get(self): return self.response({"message": "super admin only"}, status=status.HTTP_SUCCESS) diff --git a/tests/python/unit/test_middleware.py b/tests/python/unit/test_middleware.py index d7234bd..31086df 100644 --- a/tests/python/unit/test_middleware.py +++ b/tests/python/unit/test_middleware.py @@ -99,8 +99,15 @@ def test_framework_headers_awaits_async_handler(): def test_sync_middleware_propagates_async_handler_coroutine(): - request = {"path": "/x", "headers": {}, "state": {"jwt": {"roles": ["admin"]}}} - result = dispatch_route(request, _sync_invoker_like, [require_roles("admin")]) + """Sync permission middleware must still return awaitable async handler results.""" + from fusion_framework.middleware import require_permissions + + request = {"path": "/x", "headers": {}, "state": {}} + result = dispatch_route( + request, + _sync_invoker_like, + [require_permissions(lambda req: True)], + ) assert inspect.isawaitable(result) resolved = asyncio.run(result) assert resolved["body"]["ok"] is True diff --git a/tests/python/unit/test_permissions.py b/tests/python/unit/test_permissions.py new file mode 100644 index 0000000..0d2120d --- /dev/null +++ b/tests/python/unit/test_permissions.py @@ -0,0 +1,46 @@ +"""Route permission checks and Swagger security metadata.""" + +from fusion_framework._fusion import openapi_spec +from fusion_framework.api import FusionBaseApi +from fusion_framework.middleware import dispatch_route, require_permissions +from fusion_framework.route import route + + +def _handler(request): + return {"status": 200, "body": {"ok": True}} + + +def test_require_permissions_blocks(): + chain = [require_permissions(lambda req: False)] + result = dispatch_route({"headers": {}, "method": "GET"}, _handler, chain) + assert result["status"] == 403 + + +def test_require_permissions_allows(): + chain = [require_permissions(lambda req: True)] + result = dispatch_route({"headers": {}, "method": "GET"}, _handler, chain) + assert result["status"] == 200 + + +def test_openapi_marks_protected_routes(): + def is_admin(_request): + return True + + @route("/api/admin", permissions=[is_admin]) + class Admin(FusionBaseApi): + def get(self): + return {"ok": True} + + @route("/api/public") + class Public(FusionBaseApi): + def get(self): + return {"ok": True} + + spec = openapi_spec() + admin_op = spec["paths"]["/api/admin"]["get"] + public_op = spec["paths"]["/api/public"]["get"] + + assert "security" in admin_op + assert admin_op["security"] == [{"FusionPermissions": []}] + assert "security" not in public_op + assert "FusionPermissions" in spec["components"]["securitySchemes"]