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
@@ -7,24 +7,16 @@ public enum PrincipalKind
|
||||
Token,
|
||||
}
|
||||
|
||||
/// <summary>访问级别:作用域为 read 的令牌只读,admin 可以管理,release 只能登记发布清单(给 CI 用,什么都读不了)。</summary>
|
||||
public enum ApiScope
|
||||
/// <summary>
|
||||
/// 已认证的主体:会话或 API key,带着它拥有的能力集合。授权只看 <see cref="Has"/>。
|
||||
/// </summary>
|
||||
public sealed record Principal(PrincipalKind Kind, IReadOnlySet<string> Capabilities, string? UserId, string? Username, string? CsrfToken, string? TokenId)
|
||||
{
|
||||
None = 0,
|
||||
Release = 1,
|
||||
Read = 2,
|
||||
Admin = 3,
|
||||
}
|
||||
public static Principal Anonymous { get; } = new(PrincipalKind.Anonymous, new HashSet<string>(), null, null, null, null);
|
||||
|
||||
public sealed record Principal(PrincipalKind Kind, ApiScope Scope, string? UserId, string? Username, string? CsrfToken, string? TokenId)
|
||||
{
|
||||
public static Principal Anonymous { get; } = new(PrincipalKind.Anonymous, ApiScope.None, null, null, null, null);
|
||||
public bool Has(string capability) => capability == Capability.Authenticated ? Kind != PrincipalKind.Anonymous : Capabilities.Contains(capability);
|
||||
|
||||
public bool CanRead => Scope >= ApiScope.Read;
|
||||
|
||||
public bool IsAdmin => Scope >= ApiScope.Admin;
|
||||
|
||||
public bool CanPublishRelease => Scope is ApiScope.Release or ApiScope.Admin;
|
||||
public static IReadOnlySet<string> SetOf(IEnumerable<string> capabilities) => new HashSet<string>(capabilities, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public sealed record LoginResult(string SessionToken, string CsrfToken, long ExpiresAtMs, string UserId, string Username);
|
||||
|
||||
@@ -60,25 +60,24 @@ public sealed partial class AuthService
|
||||
}
|
||||
|
||||
UserRecord? user = await _store.GetUserAsync(session.UserId, ct);
|
||||
return user is null ? null : new Principal(PrincipalKind.Session, ApiScope.Admin, user.Id, user.Username, Base64Url.Encode(session.CsrfToken), null);
|
||||
return user is null ? null : new Principal(PrincipalKind.Session, Principal.SetOf(CapabilityCatalog.ForSession()), user.Id, user.Username, Base64Url.Encode(session.CsrfToken), null);
|
||||
}
|
||||
|
||||
public async ValueTask<Principal?> AuthenticateTokenAsync(string bearer, CancellationToken ct)
|
||||
{
|
||||
ApiTokenRecord? token = await _store.FindApiTokenAsync(TokenHash.Sha256(bearer), ct);
|
||||
if (token is null || token.RevokedAtMs is not null)
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
if (token is null || token.RevokedAtMs is not null || (token.ExpiresAtMs is { } expires && expires <= now))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
if (token.LastUsedAtMs is not { } last || now - last >= TokenTouchIntervalMs)
|
||||
{
|
||||
await _store.TouchApiTokenAsync(token.Id, now, ct);
|
||||
}
|
||||
|
||||
ApiScope scope = token.Scope switch { "admin" => ApiScope.Admin, "release" => ApiScope.Release, _ => ApiScope.Read };
|
||||
return new Principal(PrincipalKind.Token, scope, null, token.Name, null, token.Id);
|
||||
return new Principal(PrincipalKind.Token, Principal.SetOf(token.Capabilities), null, token.Name, null, token.Id);
|
||||
}
|
||||
|
||||
public async ValueTask LogoutAsync(string sessionToken, CancellationToken ct)
|
||||
@@ -128,20 +127,48 @@ public sealed partial class AuthService
|
||||
|
||||
public ValueTask<IReadOnlyList<UserRecord>> ListUsersAsync(CancellationToken ct) => _store.ListUsersAsync(ct);
|
||||
|
||||
public async ValueTask<Result<CreatedApiToken>> CreateApiTokenAsync(string name, string scope, CancellationToken ct)
|
||||
/// <summary>
|
||||
/// 创建 API key:能力必须都在目录里,并且是创建者自己拥有能力的子集(不能越权);写蕴含读,结果稳定排序。
|
||||
/// <paramref name="expiresInDays"/> 为空表示不过期。
|
||||
/// </summary>
|
||||
public async ValueTask<Result<CreatedApiToken>> CreateApiTokenAsync(string name, IReadOnlyList<string> capabilities, int? expiresInDays, Principal creator, CancellationToken ct)
|
||||
{
|
||||
var errors = new Dictionary<string, string>();
|
||||
if (name.Length is 0 or > 64)
|
||||
{
|
||||
return ValidationError.Fail<CreatedApiToken>("name", "must be 1-64 characters");
|
||||
errors["name"] = "must be 1-64 characters";
|
||||
}
|
||||
|
||||
if (scope is not ("read" or "admin" or "release"))
|
||||
string[] unknown = [.. capabilities.Where(c => !CapabilityCatalog.IsKnown(c))];
|
||||
if (capabilities.Count == 0)
|
||||
{
|
||||
return ValidationError.Fail<CreatedApiToken>("scope", "must be read, admin or release");
|
||||
errors["capabilities"] = "choose at least one capability";
|
||||
}
|
||||
else if (unknown.Length > 0)
|
||||
{
|
||||
errors["capabilities"] = "unknown capability: " + string.Join(", ", unknown);
|
||||
}
|
||||
|
||||
if (expiresInDays is < 1 or > 3650)
|
||||
{
|
||||
errors["expires_in_days"] = "must be between 1 and 3650";
|
||||
}
|
||||
|
||||
IReadOnlyList<string> effective = CapabilityCatalog.Expand(capabilities.Where(CapabilityCatalog.IsKnown));
|
||||
string[] beyond = [.. effective.Where(c => !creator.Has(c))];
|
||||
if (beyond.Length > 0)
|
||||
{
|
||||
errors["capabilities"] = "you cannot grant what you do not have: " + string.Join(", ", beyond);
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return ValidationError.Fail<CreatedApiToken>(errors);
|
||||
}
|
||||
|
||||
string plaintext = TokenHash.NewToken("mos");
|
||||
var record = new ApiTokenRecord(Ulid.NewPrefixed("tok", _time), name, scope, TokenHash.Sha256(plaintext), _time.GetUtcNow().ToUnixTimeMilliseconds(), null, null);
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
var record = new ApiTokenRecord(Ulid.NewPrefixed("tok", _time), name, effective, TokenHash.Sha256(plaintext), now, null, expiresInDays is { } days ? now + (days * 86_400_000L) : null, null);
|
||||
await _store.CreateApiTokenAsync(record, ct);
|
||||
return Result<CreatedApiToken>.Ok(new CreatedApiToken(record, plaintext));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 能力(capability):一项具体的权限。端点声明自己需要哪一项,<see cref="Principal.Has"/> 是唯一的判断入口,
|
||||
/// 会话、API key 都走它。设计见 docs/access.md 第 2 节。
|
||||
/// </summary>
|
||||
public static class Capability
|
||||
{
|
||||
/// <summary>任何已认证的主体(会话或 API key);只有登出、查看自己这类端点用它。</summary>
|
||||
public const string Authenticated = "authenticated";
|
||||
|
||||
public const string PublicRead = "public.read";
|
||||
|
||||
public const string FleetRead = "fleet.read";
|
||||
public const string FleetWrite = "fleet.write";
|
||||
public const string FleetEnroll = "fleet.enroll";
|
||||
public const string FleetRevoke = "fleet.revoke";
|
||||
|
||||
public const string TasksRead = "tasks.read";
|
||||
public const string TasksWrite = "tasks.write";
|
||||
|
||||
public const string DataRead = "data.read";
|
||||
public const string DataWrite = "data.write";
|
||||
|
||||
public const string SettingsRead = "settings.read";
|
||||
public const string SettingsWrite = "settings.write";
|
||||
|
||||
public const string UpdateRead = "update.read";
|
||||
public const string UpdateConfirm = "update.confirm";
|
||||
public const string ReleasePublish = "release.publish";
|
||||
|
||||
public const string KeysRead = "keys.read";
|
||||
public const string KeysManage = "keys.manage";
|
||||
|
||||
public const string AccountsManage = "accounts.manage";
|
||||
|
||||
public const string AuditRead = "audit.read";
|
||||
public const string EventsRead = "events.read";
|
||||
public const string OpsMetrics = "ops.metrics";
|
||||
|
||||
// 预留:外部 webhook 回调和数据拉取(docs/access.md 5.4),目录里先存在,实现放后面
|
||||
public const string DataExport = "data.export";
|
||||
public const string WebhooksRead = "webhooks.read";
|
||||
public const string WebhooksManage = "webhooks.manage";
|
||||
}
|
||||
|
||||
/// <summary>一项能力的元数据。<c>Trust</c>:改变信任的能力(加节点、吊销、账户),后续会要求 passkey 签名;<c>Reserved</c>:还没有对应的功能。</summary>
|
||||
public sealed record CapabilityInfo(string Id, string Group, bool Trust = false, bool Reserved = false, params string[] Implies);
|
||||
|
||||
public sealed record CapabilityPreset(string Id, IReadOnlyList<string> Capabilities);
|
||||
|
||||
/// <summary>能力目录:分组、依赖(写蕴含读)、预设,以及创建 API key 时的校验。</summary>
|
||||
public static class CapabilityCatalog
|
||||
{
|
||||
public static IReadOnlyList<CapabilityInfo> All { get; } =
|
||||
[
|
||||
new(Capability.PublicRead, "public"),
|
||||
|
||||
new(Capability.FleetRead, "fleet"),
|
||||
new(Capability.FleetWrite, "fleet", false, false, Capability.FleetRead),
|
||||
new(Capability.FleetEnroll, "fleet", true, false, Capability.FleetRead),
|
||||
new(Capability.FleetRevoke, "fleet", true, false, Capability.FleetRead),
|
||||
|
||||
new(Capability.TasksRead, "tasks"),
|
||||
new(Capability.TasksWrite, "tasks", false, false, Capability.TasksRead),
|
||||
|
||||
new(Capability.DataRead, "data"),
|
||||
new(Capability.DataWrite, "data", false, false, Capability.DataRead),
|
||||
new(Capability.DataExport, "data", false, true, Capability.DataRead),
|
||||
|
||||
new(Capability.SettingsRead, "settings"),
|
||||
new(Capability.SettingsWrite, "settings", false, false, Capability.SettingsRead),
|
||||
|
||||
new(Capability.UpdateRead, "update"),
|
||||
new(Capability.UpdateConfirm, "update", false, false, Capability.UpdateRead),
|
||||
new(Capability.ReleasePublish, "update"),
|
||||
|
||||
new(Capability.KeysRead, "keys"),
|
||||
new(Capability.KeysManage, "keys", false, false, Capability.KeysRead),
|
||||
|
||||
new(Capability.AccountsManage, "accounts", true),
|
||||
|
||||
new(Capability.EventsRead, "ops"),
|
||||
new(Capability.AuditRead, "ops"),
|
||||
new(Capability.OpsMetrics, "ops"),
|
||||
|
||||
new(Capability.WebhooksRead, "integrations", false, true),
|
||||
new(Capability.WebhooksManage, "integrations", false, true, Capability.WebhooksRead),
|
||||
];
|
||||
|
||||
private static readonly Dictionary<string, CapabilityInfo> s_byId = All.ToDictionary(c => c.Id, StringComparer.Ordinal);
|
||||
|
||||
public static IReadOnlyList<string> Groups { get; } = [.. All.Select(c => c.Group).Distinct()];
|
||||
|
||||
public static IReadOnlyList<CapabilityPreset> Presets { get; } =
|
||||
[
|
||||
new("read-only", [.. All.Where(c => !c.Reserved && c.Id.EndsWith(".read", StringComparison.Ordinal)).Select(c => c.Id)]),
|
||||
new("operations", Expand([Capability.PublicRead, Capability.FleetWrite, Capability.TasksWrite, Capability.DataWrite, Capability.SettingsWrite, Capability.UpdateConfirm, Capability.EventsRead, Capability.AuditRead])),
|
||||
new("enroll", Expand([Capability.FleetEnroll])),
|
||||
new("ci-release", [Capability.ReleasePublish]),
|
||||
new("all", [.. All.Select(c => c.Id)]),
|
||||
];
|
||||
|
||||
public static bool IsKnown(string id) => s_byId.ContainsKey(id);
|
||||
|
||||
public static CapabilityInfo? Find(string id) => s_byId.GetValueOrDefault(id);
|
||||
|
||||
/// <summary>补上蕴含的能力(写蕴含读),去重并排序,结果稳定。</summary>
|
||||
public static IReadOnlyList<string> Expand(IEnumerable<string> ids)
|
||||
{
|
||||
var result = new SortedSet<string>(StringComparer.Ordinal);
|
||||
foreach (string id in ids)
|
||||
{
|
||||
Add(result, id);
|
||||
}
|
||||
|
||||
return [.. result];
|
||||
}
|
||||
|
||||
private static void Add(SortedSet<string> set, string id)
|
||||
{
|
||||
if (id is Capability.Authenticated || !set.Add(id) || !s_byId.TryGetValue(id, out CapabilityInfo? info))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (string implied in info.Implies)
|
||||
{
|
||||
Add(set, implied);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>登录会话的能力。在 passkey 提权落地之前(docs/access.md 第 12 节步骤 2)会话直接拥有全部能力,包括信任类。</summary>
|
||||
public static IReadOnlyList<string> ForSession() => [.. All.Select(c => c.Id)];
|
||||
}
|
||||
@@ -4,7 +4,7 @@ public sealed record UserRecord(string Id, string Username, byte[] PasswordHash,
|
||||
|
||||
public sealed record SessionRecord(byte[] IdSha256, string UserId, byte[] CsrfToken, long CreatedAtMs, long ExpiresAtMs);
|
||||
|
||||
public sealed record ApiTokenRecord(string Id, string Name, string Scope, byte[] TokenSha256, long CreatedAtMs, long? LastUsedAtMs, long? RevokedAtMs);
|
||||
public sealed record ApiTokenRecord(string Id, string Name, IReadOnlyList<string> Capabilities, byte[] TokenSha256, long CreatedAtMs, long? LastUsedAtMs, long? ExpiresAtMs, long? RevokedAtMs);
|
||||
|
||||
public interface IAuthStore
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user