Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions bindings/csharp/FusionFramework/Middleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

namespace FusionFramework;

/// <summary>Custom route permission check — return <c>false</c> to deny with HTTP 403.</summary>
public delegate bool FusionPermission(FusionRequest request);

public delegate object? FusionMiddleware(FusionRequest request, Func<FusionRequest, object?> callNext);

public static class Middleware
Expand Down Expand Up @@ -186,6 +189,24 @@ public static FusionMiddleware RequireRoles(
public static FusionMiddleware RequireRoles(params string[] roles) =>
RequireRoles((IEnumerable<string>)roles);

/// <summary>Route middleware: run custom permission checks; any failure → 403.</summary>
public static FusionMiddleware RequirePermissions(params FusionPermission[] checks) =>
RequirePermissions((IEnumerable<FusionPermission>)checks);

public static FusionMiddleware RequirePermissions(IEnumerable<FusionPermission> checks)
{
var list = checks.ToList();
return (request, callNext) =>
{
foreach (var check in list)
{
if (!check(request))
return Error(403, "Forbidden");
}
return callNext(request);
};
}

/// <summary>Common security response headers.</summary>
public static FusionMiddleware SecurityHeaders(
string contentTypeOptions = "nosniff",
Expand Down
2 changes: 1 addition & 1 deletion bindings/csharp/FusionFramework/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 38 additions & 18 deletions bindings/csharp/FusionFramework/Route.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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<RouteMountSlot> Slots { get; init; } = new();
}

Expand Down Expand Up @@ -149,10 +148,8 @@ public static Type Register(
Type apiClass,
string path,
IEnumerable<FusionMiddleware>? middleware = null,
IEnumerable<string>? roles = null,
IEnumerable<FusionPermission>? permissions = null,
string? version = null,
string roleClaim = "roles",
string roleStateKey = "jwt",
IEnumerable<string>? tags = null,
string? desc = null,
string? title = null,
Expand All @@ -167,12 +164,10 @@ public static Type Register(
resolved = $"{v}/{resolved.TrimStart('/')}";

var chain = middleware?.ToList() ?? new List<FusionMiddleware>();
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<string>();
Expand All @@ -190,23 +185,50 @@ public static Type Register(
Desc = desc,
Title = title,
Deprecated = deprecated,
RequiresPermissions = requiresPermissions,
Slots = BuildSlots(apiClass, classBasePath, classTags, desc, title, deprecated),
});
}
return apiClass;
}

static IEnumerable<FusionPermission> ResolvePermissions(IEnumerable<FusionPermission>? permissions)
{
if (permissions is null) yield break;
foreach (var check in permissions)
yield return check;
}

static IEnumerable<FusionPermission> 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<T>() where T : FusionBaseApi
{
var attr = typeof(T).GetCustomAttribute<RouteAttribute>()
?? throw new InvalidOperationException($"{typeof(T).Name} needs [Route(...)]");
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,
Expand All @@ -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,
Expand Down
47 changes: 45 additions & 2 deletions bindings/csharp/FusionFramework/Swagger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/// <summary>Build a minimal OpenAPI document for unit tests without a live Swagger config.</summary>
internal static JsonObject CreateTestSpec(string? version = null)
{
var swagger = new SwaggerConfig
Expand All @@ -235,12 +256,17 @@ internal static JsonObject CreateTestSpec(string? version = null)
return BuildOpenApi(swagger, version);
}

static void FillPaths(JsonObject paths, string? versionFilter)
/// <summary>Fill OpenAPI path operations; returns true if any route requires permissions.</summary>
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)
{
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 5 additions & 3 deletions crates/fusion-node/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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): <T>(ApiClass: T) => T
Expand All @@ -98,6 +96,10 @@ export function bearerJwt(options?: {
verify?: (token: string) => Record<string, unknown> | null
}): FusionMiddleware

export function requirePermissions(
...checks: Array<(request: FusionRequest) => boolean>
): FusionMiddleware

export function requireRoles(...roles: string[]): FusionMiddleware
export function requireRoles(options: {
roles: string[]
Expand Down
50 changes: 40 additions & 10 deletions crates/fusion-node/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 = {
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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
}

Expand Down Expand Up @@ -1492,6 +1521,7 @@ module.exports = {
run,
bearerJwt,
requireRoles,
requirePermissions,
frameworkHeaders,
securityHeaders,
cors,
Expand Down
2 changes: 2 additions & 0 deletions crates/fusion-py/python/fusion_framework/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
cors,
framework_headers,
request_id,
require_permissions,
require_roles,
security_headers,
use,
Expand All @@ -26,6 +27,7 @@
"cors",
"framework_headers",
"request_id",
"require_permissions",
"require_roles",
"security_headers",
"use",
Expand Down
Loading
Loading