feat(api): add space roles with delegated member removal and structure management
This commit is contained in:
1 parent
059a29a8d5
commit
1dd54243ae
12 files changed
+551
-19
No files matched your search
@@ -6,5 +6,5 @@ public static class ApiContract
|
||||
// 去掉设备名与设备清单接口。设备只是账号的属性。
|
||||
// v8: 频道强制登录,去掉未登录访客(allow_guests、/guests、终端席位);便捷分享不受影响。
|
||||
// Keep this aligned with contracts/api-contract.json and docs/openapi.yaml.
|
||||
public const string Version = "8.1.0";
|
||||
public const string Version = "8.2.0";
|
||||
}
|
||||
@@ -172,6 +172,20 @@ public sealed class WorkspaceChannelRequest
|
||||
[JsonPropertyName("name")] public required string Name { get; init; }
|
||||
[JsonPropertyName("groupId")] public string? GroupId { get; init; }
|
||||
}
|
||||
public sealed class WorkspaceRoleRequest
|
||||
{
|
||||
[JsonPropertyName("name")] public required string Name { get; init; }
|
||||
[JsonPropertyName("prefix")] public string? Prefix { get; init; }
|
||||
[JsonPropertyName("suffix")] public string? Suffix { get; init; }
|
||||
[JsonPropertyName("color")] public string? Color { get; init; }
|
||||
[JsonPropertyName("permissions")] public required List<string> Permissions { get; init; }
|
||||
}
|
||||
public sealed class WorkspaceRoleMembersRequest { [JsonPropertyName("members")] public required List<string> Members { get; init; } }
|
||||
public sealed class WorkspaceRoleCreatedBody
|
||||
{
|
||||
[JsonPropertyName("workspace")] public required WorkspaceBody Workspace { get; init; }
|
||||
[JsonPropertyName("role")] public required WorkspaceRole Role { get; init; }
|
||||
}
|
||||
public sealed class WorkspaceGroupRequest { [JsonPropertyName("groupId")] public string? GroupId { get; init; } }
|
||||
public sealed class WorkspaceFolderRequest
|
||||
{
|
||||
@@ -222,6 +236,9 @@ public sealed class WorkspaceGroupCreatedBody
|
||||
[JsonSerializable(typeof(WorkspaceNameRequest))]
|
||||
[JsonSerializable(typeof(WorkspaceChannelRequest))]
|
||||
[JsonSerializable(typeof(WorkspaceGroupRequest))]
|
||||
[JsonSerializable(typeof(WorkspaceRoleRequest))]
|
||||
[JsonSerializable(typeof(WorkspaceRoleMembersRequest))]
|
||||
[JsonSerializable(typeof(WorkspaceRoleCreatedBody))]
|
||||
[JsonSerializable(typeof(WorkspaceFolderRequest))]
|
||||
[JsonSerializable(typeof(WorkspaceMoveRequest))]
|
||||
[JsonSerializable(typeof(GoneLogBody))]
|
||||
|
||||
@@ -44,6 +44,24 @@ public static partial class Endpoints
|
||||
});
|
||||
api.MapDelete("/spaces/{id}/groups/{groupId}", (string id, string groupId, HttpContext ctx, ChannelService svc) =>
|
||||
Json(View(svc.DeleteWorkspaceGroup(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), groupId)), ApiJson.Default.WorkspaceBody));
|
||||
api.MapPost("/spaces/{id}/roles", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceRoleRequest, ct);
|
||||
var created = svc.CreateWorkspaceRoleResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.Name, req.Prefix, req.Suffix, req.Color, req.Permissions);
|
||||
return Json(new WorkspaceRoleCreatedBody { Workspace = View(created.State), Role = created.Role }, ApiJson.Default.WorkspaceRoleCreatedBody, 201);
|
||||
});
|
||||
api.MapPatch("/spaces/{id}/roles/{roleId}", async (string id, string roleId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceRoleRequest, ct);
|
||||
return Json(View(svc.UpdateWorkspaceRole(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), roleId, req.Name, req.Prefix, req.Suffix, req.Color, req.Permissions)), ApiJson.Default.WorkspaceBody);
|
||||
});
|
||||
api.MapDelete("/spaces/{id}/roles/{roleId}", (string id, string roleId, HttpContext ctx, ChannelService svc) =>
|
||||
Json(View(svc.DeleteWorkspaceRole(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), roleId)), ApiJson.Default.WorkspaceBody));
|
||||
api.MapPut("/spaces/{id}/roles/{roleId}/members", async (string id, string roleId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceRoleMembersRequest, ct);
|
||||
return Json(View(svc.SetWorkspaceRoleMembers(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), roleId, req.Members)), ApiJson.Default.WorkspaceBody);
|
||||
});
|
||||
api.MapPost("/spaces/{id}/folders", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceFolderRequest, ct);
|
||||
|
||||
@@ -30,10 +30,18 @@ public sealed partial class ChannelService
|
||||
public IReadOnlyList<ChannelInvitation> Invitations(Id16 id, Credentials creds) => store.Read(tx =>
|
||||
{
|
||||
var (_, principal) = Authenticate(tx, id, creds, Membership.None);
|
||||
RequireOwner(principal);
|
||||
if (principal.Role != Role.Owner) RequireMemberPermission(tx, id, creds, WorkspacePermission.RemoveMembers);
|
||||
return tx.ListInvitations(id);
|
||||
});
|
||||
|
||||
/// <summary>非空间主的调用方:必须是已加入的成员,且所在身份组被授予该权限</summary>
|
||||
private Principal RequireMemberPermission(IResourceTx tx, Id16 id, Credentials creds, string permission)
|
||||
{
|
||||
var (_, principal) = Authenticate(tx, id, creds, Membership.Required);
|
||||
if (!HasWorkspacePermission(tx.GetWorkspace(id), principal, permission)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
return principal;
|
||||
}
|
||||
|
||||
public IReadOnlyList<ChannelInvitation> UserInvitations(string username) => store.Read(tx => tx.ListUserInvitations(username));
|
||||
|
||||
public void RevokeInvitation(Id16 id, Credentials creds, string username)
|
||||
@@ -41,8 +49,13 @@ public sealed partial class ChannelService
|
||||
store.Write(tx =>
|
||||
{
|
||||
var (_, principal) = Authenticate(tx, id, creds, Membership.None);
|
||||
RequireOwner(principal);
|
||||
if (tx.GetChannelMember(id, username)?.Role == "owner") throw new WebDropException(ErrorCode.Forbidden);
|
||||
if (principal.Role != Role.Owner)
|
||||
{
|
||||
// 被授权的成员不能移除自己,也不能移除同样有移除权限的人
|
||||
var acting = RequireMemberPermission(tx, id, creds, WorkspacePermission.RemoveMembers);
|
||||
if (username == acting.AccountName || HasWorkspacePermission(tx.GetWorkspace(id), new Principal(Role.Write, username), WorkspacePermission.RemoveMembers)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
}
|
||||
tx.DeleteInvitation(id, username);
|
||||
tx.DeleteKeyPackage(id, username);
|
||||
tx.DeleteChannelMember(id, username);
|
||||
|
||||
@@ -15,12 +15,22 @@ public sealed partial class ChannelService
|
||||
private static WorkspaceState NewWorkspace(string username, long now)
|
||||
{
|
||||
var id = Guid.NewGuid().ToString();
|
||||
return new() { Revision = 1, DefaultChannelId = id, Channels = [new WorkspaceChannel { Id = id, Name = "常规", CreatedAt = now, CreatedBy = username }] };
|
||||
var state = new WorkspaceState { Revision = 1, DefaultChannelId = id, Channels = [new WorkspaceChannel { Id = id, Name = "常规", CreatedAt = now, CreatedBy = username }] };
|
||||
EnsureDefaultRole(state, now);
|
||||
return state;
|
||||
}
|
||||
/// <summary>补上默认身份组(只补一次)。读取时只在内存里补,下一次写入才落库</summary>
|
||||
private static void EnsureDefaultRole(WorkspaceState state, long now)
|
||||
{
|
||||
if (state.RolesSeeded) return;
|
||||
if (!state.Roles.Any(r => r.Id == WorkspaceRole.DefaultId)) state.Roles.Insert(0, WorkspaceRole.CreateDefault(now));
|
||||
state.RolesSeeded = true;
|
||||
}
|
||||
public WorkspaceState GetWorkspace(Id16 id, Credentials creds) => store.Read(tx =>
|
||||
{
|
||||
var (space, principal) = Authenticate(tx, id, creds, Membership.Required);
|
||||
var state = tx.GetWorkspace(id) ?? new WorkspaceState();
|
||||
EnsureDefaultRole(state, clock.NowMs);
|
||||
if (principal.AccountName != space.CreatorAccount)
|
||||
{
|
||||
var crypto = tx.GetWorkspaceCrypto(id);
|
||||
@@ -38,14 +48,17 @@ public sealed partial class ChannelService
|
||||
state.Locations = state.Locations.Where(l => Id16.TryParse(l.ItemId, out var itemId) && tx.GetItem(id, itemId) is { } item && item.ExpiresAt > clock.NowMs).ToList();
|
||||
return state;
|
||||
});
|
||||
private WorkspaceState ChangeWorkspace(Id16 id, Credentials creds, Action<IResourceTx, WorkspaceState, Principal> change, bool requireAccount = true)
|
||||
private WorkspaceState ChangeWorkspace(Id16 id, Credentials creds, Action<IResourceTx, WorkspaceState, Principal> change, bool requireAccount = true, string? permission = null)
|
||||
{
|
||||
var state = store.Write(tx =>
|
||||
{
|
||||
var (channel, principal) = Authenticate(tx, id, creds, Membership.Required);
|
||||
if (requireAccount && principal.Role != Role.Owner) throw new WebDropException(ErrorCode.Forbidden);
|
||||
if (!ChannelPolicy.CanWrite(channel, principal)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
var current = tx.GetWorkspace(id) ?? new WorkspaceState();
|
||||
EnsureDefaultRole(current, clock.NowMs);
|
||||
// 被授予对应权限的身份组成员可以代行,不要求频道开放成员上传
|
||||
bool delegated = permission is not null && principal.Role != Role.Owner && HasWorkspacePermission(current, principal, permission);
|
||||
if (requireAccount && principal.Role != Role.Owner && !delegated) throw new WebDropException(ErrorCode.Forbidden);
|
||||
if (!delegated && !ChannelPolicy.CanWrite(channel, principal)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
current.Locations = current.Locations.Where(l => Id16.TryParse(l.ItemId, out var itemId) && tx.GetItem(id, itemId) is not null).ToList();
|
||||
change(tx, current, principal);
|
||||
ValidateWorkspace(current);
|
||||
@@ -68,9 +81,9 @@ public sealed partial class ChannelService
|
||||
}
|
||||
public WorkspaceState CreateWorkspaceChannel(Id16 id, Credentials creds, string name, string? groupId) => CreateWorkspaceChannelResult(id, creds, name, groupId).State;
|
||||
public WorkspaceState RenameWorkspaceChannel(Id16 id, Credentials creds, string channelId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
|
||||
FindChannel(state, channelId).Name = WorkspaceName(name));
|
||||
FindChannel(state, channelId).Name = WorkspaceName(name), permission: WorkspacePermission.ManageStructure);
|
||||
public WorkspaceState GroupWorkspaceChannel(Id16 id, Credentials creds, string channelId, string? groupId) => ChangeWorkspace(id, creds, (_, state, _) =>
|
||||
FindChannel(state, channelId).GroupId = groupId);
|
||||
FindChannel(state, channelId).GroupId = groupId, permission: WorkspacePermission.ManageStructure);
|
||||
public (WorkspaceState State, WorkspaceGroup Group) CreateWorkspaceGroupResult(Id16 id, Credentials creds, string name)
|
||||
{
|
||||
WorkspaceGroup? created = null;
|
||||
@@ -78,19 +91,64 @@ public sealed partial class ChannelService
|
||||
{
|
||||
created = new WorkspaceGroup { Id = Guid.NewGuid().ToString(), Name = WorkspaceName(name), CreatedAt = clock.NowMs };
|
||||
workspace.Groups.Add(created);
|
||||
});
|
||||
}, permission: WorkspacePermission.ManageStructure);
|
||||
return (state, created!);
|
||||
}
|
||||
public WorkspaceState CreateWorkspaceGroup(Id16 id, Credentials creds, string name) => CreateWorkspaceGroupResult(id, creds, name).State;
|
||||
public WorkspaceState RenameWorkspaceGroup(Id16 id, Credentials creds, string groupId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
|
||||
(state.Groups.Find(g => g.Id == groupId) ?? throw new WebDropException(ErrorCode.NotFound)).Name = WorkspaceName(name));
|
||||
(state.Groups.Find(g => g.Id == groupId) ?? throw new WebDropException(ErrorCode.NotFound)).Name = WorkspaceName(name), permission: WorkspacePermission.ManageStructure);
|
||||
/// <summary>删除分组:组里的频道回到未分组,频道本身和内容都不动</summary>
|
||||
public WorkspaceState DeleteWorkspaceGroup(Id16 id, Credentials creds, string groupId) => ChangeWorkspace(id, creds, (_, state, _) =>
|
||||
{
|
||||
var group = state.Groups.Find(g => g.Id == groupId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
foreach (var channel in state.Channels.Where(c => c.GroupId == groupId)) channel.GroupId = null;
|
||||
state.Groups.Remove(group);
|
||||
});
|
||||
}, permission: WorkspacePermission.ManageStructure);
|
||||
// ───── 身份组(只有空间主能增删改、分配成员) ─────
|
||||
public static bool HasWorkspacePermission(WorkspaceState? state, Principal principal, string permission) =>
|
||||
principal.Role == Role.Owner
|
||||
|| principal.AccountName is { } account && state?.Roles.Any(r => r.Members.Contains(account) && r.Permissions.Contains(permission)) == true;
|
||||
private static string? RoleLabel(string? value)
|
||||
{
|
||||
value = value?.Trim();
|
||||
if (string.IsNullOrEmpty(value)) return null;
|
||||
if (value.Length > 12 || value.Any(char.IsControl)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
return value;
|
||||
}
|
||||
private static void ApplyRole(WorkspaceRole role, string name, string? prefix, string? suffix, string? color, IReadOnlyList<string> permissions)
|
||||
{
|
||||
if (color is not null && !WorkspacePermission.Colors.Contains(color)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (permissions.Any(p => !WorkspacePermission.All.Contains(p))) throw new WebDropException(ErrorCode.BadRequest);
|
||||
role.Name = WorkspaceName(name); role.Prefix = RoleLabel(prefix); role.Suffix = RoleLabel(suffix); role.Color = color;
|
||||
role.Permissions = permissions.Distinct().ToList();
|
||||
}
|
||||
public (WorkspaceState State, WorkspaceRole Role) CreateWorkspaceRoleResult(Id16 id, Credentials creds, string name, string? prefix, string? suffix, string? color, IReadOnlyList<string> permissions)
|
||||
{
|
||||
WorkspaceRole? created = null;
|
||||
var state = ChangeWorkspace(id, creds, (_, workspace, _) =>
|
||||
{
|
||||
created = new WorkspaceRole { Id = Guid.NewGuid().ToString(), Name = "x", CreatedAt = clock.NowMs };
|
||||
ApplyRole(created, name, prefix, suffix, color, permissions);
|
||||
workspace.Roles.Add(created);
|
||||
});
|
||||
return (state, created!);
|
||||
}
|
||||
public WorkspaceState UpdateWorkspaceRole(Id16 id, Credentials creds, string roleId, string name, string? prefix, string? suffix, string? color, IReadOnlyList<string> permissions) =>
|
||||
ChangeWorkspace(id, creds, (_, state, _) => ApplyRole(FindRole(state, roleId), name, prefix, suffix, color, permissions));
|
||||
public WorkspaceState DeleteWorkspaceRole(Id16 id, Credentials creds, string roleId) =>
|
||||
ChangeWorkspace(id, creds, (_, state, _) => state.Roles.Remove(FindRole(state, roleId)));
|
||||
public WorkspaceState SetWorkspaceRoleMembers(Id16 id, Credentials creds, string roleId, IReadOnlyList<string> members) =>
|
||||
ChangeWorkspace(id, creds, (tx, state, _) =>
|
||||
{
|
||||
var role = FindRole(state, roleId);
|
||||
var distinct = members.Select(m => m.Trim().ToLowerInvariant()).Distinct().ToList();
|
||||
if (distinct.Count > 256) throw new WebDropException(ErrorCode.BadRequest);
|
||||
// 只能把已加入空间的账号放进身份组;空间主本来就有全部权限,不必加入
|
||||
foreach (var member in distinct)
|
||||
if (tx.GetChannelMember(id, member) is not { } row || row.Role == "owner") throw new WebDropException(ErrorCode.BadRequest);
|
||||
role.Members = distinct;
|
||||
});
|
||||
private static WorkspaceRole FindRole(WorkspaceState state, string id) => state.Roles.Find(r => r.Id == id) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
public WorkspaceState CreateWorkspaceFolder(Id16 id, Credentials creds, string threadId, string? parentId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
|
||||
state.Folders.Add(new WorkspaceFolder { Id = Guid.NewGuid().ToString(), ThreadId = threadId, ParentId = parentId, Name = WorkspaceName(name) }));
|
||||
public WorkspaceState RenameWorkspaceFolder(Id16 id, Credentials creds, string folderId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
|
||||
@@ -116,11 +174,12 @@ public sealed partial class ChannelService
|
||||
}
|
||||
private static void ValidateWorkspace(WorkspaceState state)
|
||||
{
|
||||
if (state.Roles is null || state.Roles.Count > 32 || state.Roles.Any(r => r is null || r.Members is null || r.Permissions is null)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (state.Channels is null || state.Groups is null || state.Folders is null || state.Locations is null
|
||||
|| state.Channels.Count > 256 || state.Groups.Count > 128 || state.Folders.Count > 2048 || state.Locations.Count > 10_000)
|
||||
throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (state.Channels.Any(c => c is null) || state.Groups.Any(g => g is null) || state.Folders.Any(f => f is null) || state.Locations.Any(l => l is null)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
var allIds = state.Channels.Select(c => c.Id).Concat(state.Groups.Select(g => g.Id)).Concat(state.Folders.Select(f => f.Id)).ToArray();
|
||||
var allIds = state.Channels.Select(c => c.Id).Concat(state.Groups.Select(g => g.Id)).Concat(state.Folders.Select(f => f.Id)).Concat(state.Roles.Select(r => r.Id)).ToArray();
|
||||
if (allIds.Any(id => !Guid.TryParseExact(id, "D", out _)) || allIds.Distinct().Count() != allIds.Length) throw new WebDropException(ErrorCode.BadRequest);
|
||||
foreach (var group in state.Groups) group.Name = WorkspaceName(group.Name);
|
||||
foreach (var channel in state.Channels)
|
||||
|
||||
@@ -35,12 +35,47 @@ public sealed class WorkspaceLocation
|
||||
[JsonPropertyName("folderId")] public string? FolderId { get; init; }
|
||||
[JsonPropertyName("authorAccount")] public string? AuthorAccount { get; init; }
|
||||
}
|
||||
/// <summary>身份组可授予的权限。空间主拥有全部权限,不属于任何身份组</summary>
|
||||
public static class WorkspacePermission
|
||||
{
|
||||
/// <summary>移除成员、撤销邀请</summary>
|
||||
public const string RemoveMembers = "members.remove";
|
||||
/// <summary>重命名频道、调整频道所属分组、管理分组(新建频道涉及密钥,仍只有空间主)</summary>
|
||||
public const string ManageStructure = "structure.manage";
|
||||
public static readonly IReadOnlyList<string> All = [RemoveMembers, ManageStructure];
|
||||
public static readonly IReadOnlyList<string> Colors = ["gray", "blue", "green", "amber", "red", "purple"];
|
||||
}
|
||||
public sealed class WorkspaceRole
|
||||
{
|
||||
/// <summary>每个空间都有的默认「管理组」,id 固定,老空间第一次读写时补上</summary>
|
||||
public const string DefaultId = "00000000-0000-4000-8000-000000000001";
|
||||
public static WorkspaceRole CreateDefault(long now) => new()
|
||||
{
|
||||
Id = DefaultId, Name = "管理组", Suffix = "管理员", Color = "blue", CreatedAt = now,
|
||||
Permissions = [.. WorkspacePermission.All],
|
||||
};
|
||||
|
||||
[JsonPropertyName("id")] public required string Id { get; init; }
|
||||
[JsonPropertyName("name")] public required string Name { get; set; }
|
||||
/// <summary>聊天里显示在名字前、后的文字(如「频道主」「管理员」),可只填其一</summary>
|
||||
[JsonPropertyName("prefix")] public string? Prefix { get; set; }
|
||||
[JsonPropertyName("suffix")] public string? Suffix { get; set; }
|
||||
[JsonPropertyName("color")] public string? Color { get; set; }
|
||||
[JsonPropertyName("permissions")] public List<string> Permissions { get; set; } = [];
|
||||
[JsonPropertyName("members")] public List<string> Members { get; set; } = [];
|
||||
[JsonPropertyName("createdAt")] public required long CreatedAt { get; init; }
|
||||
}
|
||||
public sealed class WorkspaceState
|
||||
{
|
||||
[JsonPropertyName("defaultChannelId")] public string? DefaultChannelId { get; init; }
|
||||
[JsonPropertyName("revision")] public long Revision { get; set; }
|
||||
[JsonPropertyName("channels")] public List<WorkspaceChannel> Channels { get; init; } = [];
|
||||
[JsonPropertyName("groups")] public List<WorkspaceGroup> Groups { get; init; } = [];
|
||||
/// <summary>旧版本存下来的结构里没有 roles,反序列化后是 null,这里统一当作空列表</summary>
|
||||
[JsonPropertyName("roles")] public List<WorkspaceRole> Roles { get => _roles ??= []; init => _roles = value; }
|
||||
private List<WorkspaceRole>? _roles;
|
||||
/// <summary>默认身份组已经放进来过。之后空间主删掉它不会再自动出现</summary>
|
||||
[JsonPropertyName("rolesSeeded")] public bool RolesSeeded { get; set; }
|
||||
[JsonPropertyName("folders")] public List<WorkspaceFolder> Folders { get; init; } = [];
|
||||
[JsonPropertyName("locations")] public List<WorkspaceLocation> Locations { get; set; } = [];
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user