feat!: capabilities replace API token scopes; every admin endpoint declares one, with a picker UI [CI]
This commit is contained in:
1 parent
55b80f0a97
commit
ddb6608e32
38 files changed
+1148
-218
No files matched your search
@@ -9,6 +9,19 @@ public static class EndpointConventions
|
||||
public static RouteHandlerBuilder Api(this RouteHandlerBuilder builder, string operationId, string summary, string tag) =>
|
||||
builder.WithName(operationId).WithSummary(summary).WithTags(tag);
|
||||
|
||||
/// <summary>声明端点需要的能力(授权的唯一依据);OpenAPI 里带上 <c>x-moshiro-capability</c>。</summary>
|
||||
public static RouteHandlerBuilder Requires(this RouteHandlerBuilder builder, string capability) =>
|
||||
builder.WithMetadata(new CapabilityRequirement(capability)).AddOpenApiOperationTransformer((operation, _, _) =>
|
||||
{
|
||||
operation.Extensions ??= new Dictionary<string, Microsoft.OpenApi.IOpenApiExtension>();
|
||||
operation.Extensions["x-moshiro-capability"] = new Microsoft.OpenApi.JsonNodeExtension(System.Text.Json.Nodes.JsonValue.Create(capability));
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
/// <summary>匿名可访问的管理端点(登录):显式声明,对账测试才能区分「忘了声明」。</summary>
|
||||
public static RouteHandlerBuilder AllowsAnonymous(this RouteHandlerBuilder builder) =>
|
||||
builder.WithMetadata(new CapabilityRequirement(null));
|
||||
|
||||
public static RouteHandlerBuilder Returns<T>(this RouteHandlerBuilder builder, int status = StatusCodes.Status200OK) =>
|
||||
builder.Produces<T>(status);
|
||||
|
||||
|
||||
@@ -53,17 +53,29 @@ public static class PrincipalResolver
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>管理层守卫:需要 admin 作用域;Cookie 会话的非 GET 请求必须带 <c>X-CSRF-Token</c>。</summary>
|
||||
public static class AdminGuard
|
||||
/// <summary>
|
||||
/// 端点声明自己需要的能力(<see cref="EndpointConventions.Requires"/>)。<c>Capability == null</c> 表示匿名可访问(登录)。
|
||||
/// 没有声明的 <c>/api/admin</c> 端点一律拒绝(并有对账测试保证不存在)。
|
||||
/// </summary>
|
||||
public sealed record CapabilityRequirement(string? Capability);
|
||||
|
||||
/// <summary>管理层守卫:按端点声明的能力授权;Cookie 会话的非 GET 请求必须带 <c>X-CSRF-Token</c>。</summary>
|
||||
public static class CapabilityGuard
|
||||
{
|
||||
public static async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||
{
|
||||
HttpContext http = context.HttpContext;
|
||||
Principal principal = http.GetPrincipal();
|
||||
if (!principal.IsAdmin)
|
||||
CapabilityRequirement? requirement = http.GetEndpoint()?.Metadata.GetMetadata<CapabilityRequirement>();
|
||||
if (requirement is null)
|
||||
{
|
||||
return principal.Scope != ApiScope.None
|
||||
? Problems.Create(http, MoShiroErrors.Forbidden, "the token scope does not allow this operation")
|
||||
return Problems.Create(http, MoShiroErrors.Forbidden, "this endpoint has no capability declaration");
|
||||
}
|
||||
|
||||
Principal principal = http.GetPrincipal();
|
||||
if (requirement.Capability is { } capability && !principal.Has(capability))
|
||||
{
|
||||
return principal.Kind != PrincipalKind.Anonymous
|
||||
? Problems.Create(http, MoShiroErrors.Forbidden, "the credential lacks the capability: " + capability)
|
||||
: Problems.Create(http, MoShiroErrors.Unauthorized, "authentication is required");
|
||||
}
|
||||
|
||||
@@ -80,20 +92,13 @@ public static class AdminGuard
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>登记发布清单的守卫:管理员(会话要带 CSRF)或 release 令牌;release 令牌只能做这一件事。</summary>
|
||||
public static class ReleaseGuard
|
||||
{
|
||||
public static async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) =>
|
||||
context.HttpContext.GetPrincipal().Scope == ApiScope.Release ? await next(context) : await AdminGuard.InvokeAsync(context, next);
|
||||
}
|
||||
|
||||
/// <summary>展示层守卫:站点公开时匿名可读;私有时需要登录或只读令牌。</summary>
|
||||
public static class PublicGuard
|
||||
{
|
||||
public static async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||
{
|
||||
HttpContext http = context.HttpContext;
|
||||
if (!http.GetPrincipal().CanRead && !await http.RequestServices.GetRequiredService<SettingsService>().IsSitePublicAsync(http.RequestAborted))
|
||||
if (!http.GetPrincipal().Has(Capability.PublicRead) && !await http.RequestServices.GetRequiredService<SettingsService>().IsSitePublicAsync(http.RequestAborted))
|
||||
{
|
||||
return Problems.Create(http, MoShiroErrors.SitePrivate, "this site is private; sign in or use a read-only token");
|
||||
}
|
||||
@@ -102,5 +107,5 @@ public static class PublicGuard
|
||||
}
|
||||
|
||||
/// <summary>被隐藏(public_visible = false)的线路只对已认证的访问者可见。</summary>
|
||||
public static bool CanSeeHidden(HttpContext http) => http.GetPrincipal().CanRead;
|
||||
public static bool CanSeeHidden(HttpContext http) => http.GetPrincipal().Has(Capability.PublicRead);
|
||||
}
|
||||
@@ -156,7 +156,7 @@ public static class Mappers
|
||||
public static JoinTokenDto ToDto(JoinTokenInfo t) =>
|
||||
new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind));
|
||||
|
||||
public static ApiTokenDto ToDto(ApiTokenRecord t) => new(t.Id, t.Name, t.Scope, Time(t.CreatedAtMs), Time(t.LastUsedAtMs), Time(t.RevokedAtMs));
|
||||
public static ApiTokenDto ToDto(ApiTokenRecord t) => new(t.Id, t.Name, t.Capabilities, Time(t.CreatedAtMs), Time(t.LastUsedAtMs), Time(t.ExpiresAtMs), Time(t.RevokedAtMs));
|
||||
|
||||
public static RawChunkMetaDto ToDto(RawChunkMeta m) =>
|
||||
new(m.ChunkId.ToString(), m.AgentId.Value, m.Series, m.Kind, m.TaskId?.Value, m.TestId?.Value, m.Phase, Time(m.StartTs), m.DurationMs, m.Partial, m.Length);
|
||||
|
||||
Reference in new issue
Block a user