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"]