diff --git a/bench/main.zig b/bench/main.zig index c08940b..76556aa 100644 --- a/bench/main.zig +++ b/bench/main.zig @@ -910,8 +910,10 @@ fn seedSpaceRecords( .space_type = space_type, .skey = "self", .is_authority = true, - .managing_app = "https://api-stg.plyr.fm", - .policy = "member-list", + .read_managing_app = "https://api-stg.plyr.fm", + .read_policy = "member-list", + .write_managing_app = "https://api-stg.plyr.fm", + .write_policy = "member-list", .app_access_json = "{\"type\":\"open\"}", }); for (0..records) |i| { diff --git a/docs/benchmarking.md b/docs/benchmarking.md index 1b8462d..df93ed8 100644 --- a/docs/benchmarking.md +++ b/docs/benchmarking.md @@ -302,3 +302,31 @@ With payloads in the fixture, five-run macOS ReleaseSafe median readiness fell from 33.86 ms (commit index only) to 9.22 ms (partial index plus per-table sequence maxima). Empty restarts measured 9.16 ms. These warm-cache results are distinct from the initial small-block comparison above. + +### September 2026: independent simplespace read/write access + +On macOS arm64, Zig 0.16.0, ReleaseFast, three `just bench space 1000` +runs before and after the API/storage update gave these median throughputs: + +| storage operation | before ops/s | after ops/s | +| --- | ---: | ---: | +| listSpaces | 56,055 | 54,399 | +| listMembers | 38,596 | 37,099 | +| createRecord | 7,389 | 7,224 | +| getRecord | 180,344 | 174,748 | +| listRecords | 44,609 | 43,957 | +| getRepo | 624 | 628 | + +These sequential local samples show small changes (roughly 1–4% on most read +paths), not a production capacity estimate. The member list now returns two +additional flags and space configuration reads return independent policies. +At 10,000 records, a single export probe measured 69.0 ops/s before and 68.6 +after; createRecord measured 7,016 before and 7,319 after. Membership size is +not scaled with record count in this fixture. + +A ReleaseSafe startup rehearsal on a consistent production SQLite backup +preserved account, session, OAuth, public block, permissioned record, space +configuration, and membership state. The migration stage took 10 ms locally; +first readiness was 336 ms and a subsequent restart reached readiness in +11 ms. These are individual warm-filesystem samples, not Fly replacement +or network interruption measurements. diff --git a/docs/permissioned-data.md b/docs/permissioned-data.md index 1b146eb..b728517 100644 --- a/docs/permissioned-data.md +++ b/docs/permissioned-data.md @@ -78,22 +78,20 @@ ZDS splits protocol data routes from baseline PDS management routes: full and incremental repo sync, blob enumeration, notification registration and removal, write notifications, and deletion notifications - `com.atproto.simplespace.*`: `createSpace`, `getSpace`, `updateSpace`, - `deleteSpace`, `addMember`, `removeMember`, `listMembers`, and the + `deleteSpace`, `putMember`, `removeMember`, `listMembers`, and the managing-app `checkUserAccess` hook ZDS uses the proposal names for the credential and sync-read surface: `getDelegationToken` and `listRepoOps`. `createSpace` anchors the space on the authenticated DID and takes -`{type, skey?, policy, appAccess}`. `policy` is one of the +`{type, skey?, readPolicy, writePolicy, appAccess}`. Both policies use the `com.atproto.simplespace.defs` unions `#publicPolicy`, `#memberListPolicy`, or `#managingAppPolicy{managingApp}`; `appAccess` is `#open` or -`#allowList{allowed}`. A variant the host does not implement answers -`UnsupportedPolicy` / `UnsupportedAppAccess`. `getSpace` returns -`{uri, policy, appAccess}` in the same shapes; `updateSpace` accepts either or -both. Internally the policy is still stored as `public` / `member-list` / -`managing-app` plus a nullable managing app, so the union is a wire shape, not -a storage change. +`#allowList{allowed}`. `getSpace` returns both policies. `updateSpace` replaces +only supplied fields. `putMember` upserts required `read` and `write` booleans. +The old `policy` field is rejected and `addMember` is removed. See the +[client and storage migration guide](simplespace-upgrade.md). `listRepoOps` pages with an opaque `rev/idx` cursor: a full page (`limit`, default 100, max 1000) carries `cursor` and no `commit`; the page that reaches @@ -134,14 +132,16 @@ permissioned space rather than relying on a universal PDS-wide member list. `simplespace` membership is stored only as baseline space-management policy state. It is not exposed as a protocol sync surface. For a `member-list` space, -ZDS mints credentials and allows bearer reads/writes only for listed DIDs. For +ZDS checks the member's read flag for credentials and write flag for writer +authorization. Each policy is independent. For `public`, any requester can be authorized. For `managing-app`, the authority calls the configured service's `com.atproto.simplespace.checkUserAccess` method -with service auth and fails closed on resolution, transport, or response errors. +with `access=read` or `access=write` and service auth. Write checks omit +`clientId`. Resolution, transport, and response errors deny access. The generic PDS implementation of that method returns `authorized: false`; policy services replace it with their own application-layer decision. -`policy` and `appAccess` use the lexicon-union wire shape on every +`readPolicy`, `writePolicy`, and `appAccess` use the lexicon-union wire shape on every `simplespace` route: `{"$type":"com.atproto.simplespace.defs#memberListPolicy"}`, `{"$type":"com.atproto.simplespace.defs#managingAppPolicy","managingApp":"did:…#svc"}`, `{"$type":"com.atproto.simplespace.defs#open"}`, or diff --git a/docs/simplespace-upgrade.md b/docs/simplespace-upgrade.md new file mode 100644 index 0000000..7eeccc1 --- /dev/null +++ b/docs/simplespace-upgrade.md @@ -0,0 +1,65 @@ +# Simplespace independent read/write access + +ZDS follows the September 9, 2026 permissioned-data lexicons ([upstream change](https://github.com/bluesky-social/atproto/commit/1ce408be0edc71778a80eb0aa744812cfcb67390)). This experimental API update intentionally removes the previous request shapes. + +## Client changes + +- Replace `addMember({space, did})` with `putMember({space, did, read, write})`. + Both flags are required booleans. This is an upsert: subsequent calls replace + both permissions, including `false` values. +- Replace `policy` in `createSpace` with `readPolicy` and `writePolicy`. Both are + required and accept the same lexicon policy unions as before. +- `updateSpace` accepts either policy independently; omitted fields stay unchanged. + A supplied old `policy` field returns `InvalidRequest`, including when mixed + with the new fields. +- Read both policies from `getSpace`; the old `policy` output is removed. +- Read `read` and `write` from each `listMembers` entry. The page default is 100, + maximum 1000. The owner needs a covering read grant to enumerate members. +- Managing apps receive `checkUserAccess` with `access=read` or `access=write`. + Write checks omit `clientId`. Each policy can name a different managing app. + +For example, a public-readable space with selected writers: + +```json +{ + "type": "example.shared.notes", + "readPolicy": {"$type": "com.atproto.simplespace.defs#publicPolicy"}, + "writePolicy": {"$type": "com.atproto.simplespace.defs#memberListPolicy"}, + "appAccess": {"$type": "com.atproto.simplespace.defs#open"} +} +``` + +Read policy governs credential issuance. Write policy governs which writers the +space authority tracks and forwards notifications for. ZDS also checks write +policy synchronously when it hosts the authority and another local account +writes into the space. The authority retains access to its own space. + +## Stored data + +Startup migrates the previous SQLite schema in one transaction: + +- The previous policy and managing app become the read policy and managing app. +- The write policy and managing app start with the same values. +- Every existing member starts with read and write enabled. +- Records, memberships, app-access configuration, and deleted-space tombstones + are retained. Future restarts do not reapply the access defaults. + +This preserves previous access while removing compatibility code from the API. +Old clients must update; no legacy route or single-policy translation remains. +Existing already-issued credentials retain their normal expiry behavior. + +Before deploying, take a consistent database backup and volume snapshot, rehearse +startup on a disposable copy, and compare the account, policy, membership, and +record state. A binary rollback cannot safely represent independently changed +permissions using the old schema. + +## Validation + +`just test` covers migration preservation and repeat execution, member upserts, +and independent policy decisions. `just smoke-permissioned` exercises read-only, +write-only, neither, and both permissions through credential issuance and actual +record writes, plus old payload rejection and partial policy updates. + +Use `just bench space 1000` and `just bench space 10000` for the storage baseline. +These are single-caller storage measurements; they do not measure remote +managing-app latency. Use `just bench http` separately for the public HTTP paths. diff --git a/src/atproto/space.zig b/src/atproto/space.zig index 8851128..d3d624c 100644 --- a/src/atproto/space.zig +++ b/src/atproto/space.zig @@ -63,7 +63,7 @@ pub fn dispatch(request: *http_api.Request) !void { if (std.mem.eql(u8, method, "com.atproto.simplespace.createSpace")) return createSimpleSpace(request); if (std.mem.eql(u8, method, "com.atproto.simplespace.updateSpace")) return updateSimpleSpace(request); if (std.mem.eql(u8, method, "com.atproto.simplespace.deleteSpace")) return deleteSimpleSpace(request); - if (std.mem.eql(u8, method, "com.atproto.simplespace.addMember")) return addSimpleSpaceMember(request); + if (std.mem.eql(u8, method, "com.atproto.simplespace.putMember")) return putSimpleSpaceMember(request); if (std.mem.eql(u8, method, "com.atproto.simplespace.removeMember")) return removeSimpleSpaceMember(request); if (std.mem.eql(u8, method, "com.atproto.simplespace.listMembers")) return listSimpleSpaceMembers(request); if (std.mem.eql(u8, method, "com.atproto.simplespace.checkUserAccess")) return checkSimpleSpaceUserAccess(request); @@ -93,8 +93,11 @@ fn createSimpleSpace(request: *http_api.Request) !void { }; const skey = zat.json.getString(input, "skey") orelse try store.generateRkey(allocator); try requireSpaceScope(request, auth_ctx.oauth_scope, space_type, authority, skey, .manage_create, null); - const policy_value = jsonField(input, "policy") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing policy"); - const policy = policyFromLex(policy_value) catch |err| return rejectPolicy(request, err); + if (jsonField(input, "policy") != null) return http_api.xrpcError(request, .bad_request, "InvalidRequest", "policy was replaced by readPolicy and writePolicy"); + const read_value = jsonField(input, "readPolicy") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing readPolicy"); + const read_policy = policyFromLex(read_value) catch |err| return rejectPolicy(request, err); + const write_value = jsonField(input, "writePolicy") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing writePolicy"); + const write_policy = policyFromLex(write_value) catch |err| return rejectPolicy(request, err); const app_access_value = jsonField(input, "appAccess") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing appAccess"); const app_access_json = appAccessFromLex(allocator, app_access_value) catch |err| return rejectAppAccess(request, err); @@ -104,8 +107,10 @@ fn createSimpleSpace(request: *http_api.Request) !void { .space_type = space_type, .skey = skey, .is_authority = true, - .managing_app = policy.managing_app, - .policy = policy.policy, + .read_managing_app = read_policy.managing_app, + .read_policy = read_policy.policy, + .write_managing_app = write_policy.managing_app, + .write_policy = write_policy.policy, .app_access_json = app_access_json, }) catch |err| switch (err) { error.InvalidRepoPath => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid authority"), @@ -222,15 +227,15 @@ fn listRepos(request: *http_api.Request) !void { return http_api.json(request, .ok, out.written()); } -fn addSimpleSpaceMember(request: *http_api.Request) !void { - return mutateSimpleSpaceMember(request, .add); +fn putSimpleSpaceMember(request: *http_api.Request) !void { + return mutateSimpleSpaceMember(request, .put); } fn removeSimpleSpaceMember(request: *http_api.Request) !void { return mutateSimpleSpaceMember(request, .remove); } -const MemberMutation = enum { add, remove }; +const MemberMutation = enum { put, remove }; fn mutateSimpleSpaceMember(request: *http_api.Request, mutation: MemberMutation) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); @@ -244,9 +249,11 @@ fn mutateSimpleSpaceMember(request: *http_api.Request, mutation: MemberMutation) const input = parsed_body.value; const space = zat.json.getString(input, "space") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); const member = zat.json.getString(input, "did") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing did"); - try requireSimpleSpaceAuthority(request, allocator, auth_ctx, space); + try requireSimpleSpaceAuthority(request, allocator, auth_ctx, space, .manage_update); + const read = if (mutation == .put) requiredBool(input, "read") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "read must be a boolean") else false; + const write = if (mutation == .put) requiredBool(input, "write") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "write must be a boolean") else false; (switch (mutation) { - .add => store.addSimpleSpaceMember(space, member), + .put => store.putSimpleSpaceMember(space, member, read, write), .remove => store.removeSimpleSpaceMember(space, member), }) catch |err| switch (err) { error.InvalidRepoPath => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid DID"), @@ -264,16 +271,16 @@ fn listSimpleSpaceMembers(request: *http_api.Request) !void { const auth_ctx = requireAccount(request, allocator) catch return; var space_buf: [1024]u8 = undefined; const space = http_api.queryParam(request.url.raw, "space", &space_buf) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); - try requireSimpleSpaceAuthority(request, allocator, auth_ctx, space); + try requireSimpleSpaceAuthority(request, allocator, auth_ctx, space, .read_self); var cursor_buf: [256]u8 = undefined; const maybe_cursor = http_api.queryParam(request.url.raw, "cursor", &cursor_buf); - const members = try store.listSimpleSpaceMembers(allocator, space, maybe_cursor, http_api.queryLimit(request.url.raw, 50)); + const members = try store.listSimpleSpaceMembers(allocator, space, maybe_cursor, pageLimit(request.url.raw, 100, 1000)); var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); try out.writer.writeAll("{\"members\":["); for (members, 0..) |member, idx| { if (idx != 0) try out.writer.writeByte(','); - try out.writer.print("{{\"did\":{f},\"createdAt\":{}}}", .{ std.json.fmt(member.did, .{}), member.created_at }); + try out.writer.print("{{\"did\":{f},\"read\":{},\"write\":{}}}", .{ std.json.fmt(member.did, .{}), member.read, member.write }); } try out.writer.writeByte(']'); if (members.len > 0) try out.writer.print(",\"cursor\":{f}", .{std.json.fmt(members[members.len - 1].did, .{})}); @@ -298,6 +305,9 @@ fn checkSimpleSpaceUserAccess(request: *http_api.Request) !void { if (!serviceAudienceMatchesPds(service.audience)) { return http_api.xrpcError(request, .unauthorized, "BadJwtAudience", "JWT audience does not identify this service"); } + var access_buf: [16]u8 = undefined; + const access = http_api.queryParam(request.url.raw, "access", &access_buf) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing access"); + if (!std.mem.eql(u8, access, "read") and !std.mem.eql(u8, access, "write")) return http_api.xrpcError(request, .bad_request, "InvalidRequest", "access must be read or write"); return http_api.json(request, .ok, "{\"authorized\":false}"); } @@ -318,7 +328,12 @@ fn updateSimpleSpace(request: *http_api.Request) !void { const existing = (try store.getSpace(allocator, auth_ctx.account.did, space)) orelse return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); if (!existing.is_authority) return http_api.xrpcError(request, .forbidden, "NotSpaceOwner", "Not the space owner"); if (existing.deleted_at != null) return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); - const maybe_policy: ?store.SimpleSpacePolicy = if (jsonField(input, "policy")) |value| + if (jsonField(input, "policy") != null) return http_api.xrpcError(request, .bad_request, "InvalidRequest", "policy was replaced by readPolicy and writePolicy"); + const maybe_read: ?store.SimpleSpacePolicy = if (jsonField(input, "readPolicy")) |value| + policyFromLex(value) catch |err| return rejectPolicy(request, err) + else + null; + const maybe_write: ?store.SimpleSpacePolicy = if (jsonField(input, "writePolicy")) |value| policyFromLex(value) catch |err| return rejectPolicy(request, err) else null; @@ -326,7 +341,7 @@ fn updateSimpleSpace(request: *http_api.Request) !void { appAccessFromLex(allocator, value) catch |err| return rejectAppAccess(request, err) else null; - store.updateSimpleSpaceConfig(space, maybe_policy, maybe_app_access) catch |err| switch (err) { + store.updateSimpleSpaceConfig(space, maybe_read, maybe_write, maybe_app_access) catch |err| switch (err) { error.RepoNotFound => return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"), error.InvalidRecordType => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid simplespace config"), else => return err, @@ -884,12 +899,12 @@ fn getSpaceCredential(request: *http_api.Request) !void { }; } else null; const attested_client_id = if (attestation_claims) |claims| claims.client_id else null; - if (!try spacePolicyAllowsRequester(allocator, config_row, delegation.requester_did, attested_client_id)) { - return http_api.xrpcError(request, .forbidden, "UserNotAuthorized", "The space host did not grant access to this requester"); - } if (!spaceAllowsClient(allocator, config_row, attested_client_id)) { return http_api.xrpcError(request, .forbidden, "AppNotAuthorized", "This space requires a verified client attestation"); } + if (!try spacePolicyAllowsRequester(allocator, config_row, delegation.requester_did, .read, attested_client_id)) { + return http_api.xrpcError(request, .forbidden, "UserNotAuthorized", "The space host did not grant access to this requester"); + } const attestation_replay: ?store.SpaceReplayToken = if (attestation_claims) |claims| .{ .jti = claims.jti, .expires_at = claims.exp } else null; if (!try store.consumeSpaceCredentialExchange( .{ .jti = delegation.jti, .expires_at = delegation.exp }, @@ -926,7 +941,7 @@ fn notifyWrite(request: *http_api.Request) !void { const authority = (store.findAccount(allocator, parsed.authority_did) catch null) orelse return http_api.json(request, .ok, "{}"); const config_row = (try store.getSpace(allocator, parsed.authority_did, space)) orelse return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); if (config_row.deleted_at != null) return http_api.xrpcError(request, .bad_request, "SpaceDeleted", "Space has been deleted"); - if (!try spacePolicyAllowsRequester(allocator, config_row, repo, null)) { + if (!try spacePolicyAllowsRequester(allocator, config_row, repo, .write, null)) { return http_api.xrpcError(request, .forbidden, "UserNotAuthorized", "Writer is not authorized for this space"); } try store.recordSpaceWriter(space, repo, rev, hash); @@ -1164,13 +1179,38 @@ fn serviceEndpointForId(allocator: std.mem.Allocator, service_id: []const u8) ![ return allocator.dupe(u8, doc.pdsEndpoint() orelse return error.MissingServiceEndpoint); } +fn managingAppAccessUrl( + allocator: std.mem.Allocator, + endpoint: []const u8, + space: []const u8, + requester_did: []const u8, + access: store.SpaceAccess, + client_id: ?[]const u8, +) ![]const u8 { + const encoded_space = try percentEncode(allocator, space); + const encoded_user = try percentEncode(allocator, requester_did); + return if (if (access == .read) client_id else null) |id| + try std.fmt.allocPrint( + allocator, + "{s}/xrpc/com.atproto.simplespace.checkUserAccess?space={s}&user={s}&access={s}&clientId={s}", + .{ endpoint, encoded_space, encoded_user, @tagName(access), try percentEncode(allocator, id) }, + ) + else + try std.fmt.allocPrint( + allocator, + "{s}/xrpc/com.atproto.simplespace.checkUserAccess?space={s}&user={s}&access={s}", + .{ endpoint, encoded_space, encoded_user, @tagName(access) }, + ); +} + fn managingAppAllowsRequester( allocator: std.mem.Allocator, space: store.SpaceConfig, requester_did: []const u8, + access: store.SpaceAccess, client_id: ?[]const u8, ) !bool { - const managing_app = space.managing_app orelse return false; + const managing_app = (if (access == .read) space.read_managing_app else space.write_managing_app) orelse return false; const endpoint = serviceEndpointForId(allocator, managing_app) catch return false; const parsed = parseSpaceUri(space.uri) orelse return false; const authority = (try store.findAccount(allocator, parsed.authority_did)) orelse return false; @@ -1184,20 +1224,7 @@ fn managingAppAllowsRequester( &keypair, ); const authorization = try std.fmt.allocPrint(allocator, "Bearer {s}", .{token}); - const encoded_space = try percentEncode(allocator, space.uri); - const encoded_user = try percentEncode(allocator, requester_did); - const url = if (client_id) |id| - try std.fmt.allocPrint( - allocator, - "{s}/xrpc/com.atproto.simplespace.checkUserAccess?space={s}&user={s}&clientId={s}", - .{ endpoint, encoded_space, encoded_user, try percentEncode(allocator, id) }, - ) - else - try std.fmt.allocPrint( - allocator, - "{s}/xrpc/com.atproto.simplespace.checkUserAccess?space={s}&user={s}", - .{ endpoint, encoded_space, encoded_user }, - ); + const url = try managingAppAccessUrl(allocator, endpoint, space.uri, requester_did, access, client_id); var transport = zat.HttpTransport.init(store.currentIo(), allocator); defer transport.deinit(); var result = transport.fetch(.{ @@ -1340,7 +1367,7 @@ fn requireSpaceAccess( if (try store.getSpace(allocator, parsed.authority_did, space)) |authority_row| { if (authority_row.is_authority) { if (authority_row.deleted_at != null) return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space has been deleted"); - if (!try spacePolicyAllowsRequester(allocator, authority_row, auth_ctx.account.did, null)) { + if (!try spacePolicyAllowsRequester(allocator, authority_row, auth_ctx.account.did, .write, null)) { return http_api.xrpcError(request, .forbidden, "NotPermitted", "Not permitted to write in this space"); } } @@ -1360,8 +1387,10 @@ fn requireSpaceAccess( .space_type = parsed.space_type, .skey = parsed.skey, .is_authority = false, - .managing_app = null, - .policy = "member-list", + .read_managing_app = null, + .read_policy = "member-list", + .write_managing_app = null, + .write_policy = "member-list", .app_access_json = "{\"type\":\"open\"}", }) catch { try http_api.xrpcError(request, .forbidden, "NotPermitted", "Not permitted to write in this space"); @@ -1374,6 +1403,7 @@ fn requireSimpleSpaceAuthority( allocator: std.mem.Allocator, auth_ctx: http_api.BearerAccount, space: []const u8, + action: scopes.SpaceAction, ) !void { const parsed = parseSpaceUri(space) orelse { try http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); @@ -1383,7 +1413,7 @@ fn requireSimpleSpaceAuthority( try http_api.xrpcError(request, .forbidden, "NotSpaceOwner", "Not the space owner"); return error.HandledResponse; } - try requireSpaceScope(request, auth_ctx.oauth_scope, parsed.space_type, parsed.authority_did, parsed.skey, .manage_update, null); + try requireSpaceScope(request, auth_ctx.oauth_scope, parsed.space_type, parsed.authority_did, parsed.skey, action, null); const existing = (try store.getSpace(allocator, auth_ctx.account.did, space)) orelse { try http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); return error.HandledResponse; @@ -1614,6 +1644,7 @@ fn spacePolicyAllowsRequester( allocator: std.mem.Allocator, space: store.SpaceConfig, requester_did: []const u8, + access: store.SpaceAccess, client_id: ?[]const u8, ) !bool { // The authority is the only party that can reconfigure the space, so it must @@ -1621,9 +1652,10 @@ fn spacePolicyAllowsRequester( // depend on a managing app being reachable. Matches the reference PDS // (SimpleSpaceManager.authorizeUser). if (std.mem.eql(u8, requester_did, space.authority_did)) return true; - if (std.mem.eql(u8, space.policy, "public")) return true; - if (std.mem.eql(u8, space.policy, "member-list")) return store.simpleSpaceHasMember(space.uri, requester_did); - if (std.mem.eql(u8, space.policy, "managing-app")) return managingAppAllowsRequester(allocator, space, requester_did, client_id); + const policy = if (access == .read) space.read_policy else space.write_policy; + if (std.mem.eql(u8, policy, "public")) return true; + if (std.mem.eql(u8, policy, "member-list")) return store.simpleSpaceMemberAllows(space.uri, requester_did, access); + if (std.mem.eql(u8, policy, "managing-app")) return managingAppAllowsRequester(allocator, space, requester_did, access, client_id); return false; } @@ -1654,6 +1686,11 @@ fn parseAtJsonBytes(allocator: std.mem.Allocator, value: std.json.Value) ![]cons return cbor_json.decodeAtBase64(allocator, encoded); } +fn requiredBool(value: std.json.Value, key: []const u8) ?bool { + const field = jsonField(value, key) orelse return null; + return if (field == .bool) field.bool else null; +} + fn jsonField(value: std.json.Value, key: []const u8) ?std.json.Value { return switch (value) { .object => |object| object.get(key), @@ -1753,9 +1790,10 @@ fn lexPolicyJson(allocator: std.mem.Allocator, policy: []const u8, managing_app: } fn simpleSpaceViewJson(allocator: std.mem.Allocator, space: store.SpaceConfig) ![]const u8 { - return std.fmt.allocPrint(allocator, "{{\"uri\":{f},\"policy\":{s},\"appAccess\":{s}}}", .{ + return std.fmt.allocPrint(allocator, "{{\"uri\":{f},\"readPolicy\":{s},\"writePolicy\":{s},\"appAccess\":{s}}}", .{ std.json.fmt(space.uri, .{}), - try lexPolicyJson(allocator, space.policy, space.managing_app), + try lexPolicyJson(allocator, space.read_policy, space.read_managing_app), + try lexPolicyJson(allocator, space.write_policy, space.write_managing_app), try lexAppAccessJson(allocator, space.app_access_json), }); } @@ -1869,14 +1907,16 @@ test "simplespace getSpace view matches the alpha lexicon" { .authority_did = "did:plc:abc", .space_type = "my.bulletin.board", .skey = "self", - .managing_app = "did:web:bulletin.my#bulletin", - .policy = "managing-app", + .read_managing_app = "did:web:bulletin.my#bulletin", + .read_policy = "managing-app", + .write_managing_app = "did:web:bulletin.my#bulletin", + .write_policy = "managing-app", .app_access_json = "{\"type\":\"open\"}", .is_authority = true, .deleted_at = null, }); try std.testing.expectEqualStrings( - "{\"uri\":\"at://did:plc:abc/space/my.bulletin.board/self\",\"policy\":{\"$type\":\"com.atproto.simplespace.defs#managingAppPolicy\",\"managingApp\":\"did:web:bulletin.my#bulletin\"},\"appAccess\":{\"$type\":\"com.atproto.simplespace.defs#open\"}}", + "{\"uri\":\"at://did:plc:abc/space/my.bulletin.board/self\",\"readPolicy\":{\"$type\":\"com.atproto.simplespace.defs#managingAppPolicy\",\"managingApp\":\"did:web:bulletin.my#bulletin\"},\"writePolicy\":{\"$type\":\"com.atproto.simplespace.defs#managingAppPolicy\",\"managingApp\":\"did:web:bulletin.my#bulletin\"},\"appAccess\":{\"$type\":\"com.atproto.simplespace.defs#open\"}}", view, ); } @@ -1887,15 +1927,17 @@ test "the space authority is always authorized for its own space" { .authority_did = "did:plc:auth", .space_type = "my.bulletin.board", .skey = "self", - .managing_app = "did:web:unreachable.invalid#bulletin", - .policy = "managing-app", + .read_managing_app = "did:web:unreachable.invalid#bulletin", + .read_policy = "managing-app", + .write_managing_app = "did:web:unreachable.invalid#bulletin", + .write_policy = "managing-app", .app_access_json = "{\"type\":\"open\"}", .is_authority = true, .deleted_at = null, }; // Returns before the managing-app branch would try to resolve the (here // deliberately unreachable) service, so no allocator or store is touched. - try std.testing.expect(try spacePolicyAllowsRequester(std.testing.allocator, managing_app_space, "did:plc:auth", null)); + try std.testing.expect(try spacePolicyAllowsRequester(std.testing.allocator, managing_app_space, "did:plc:auth", .read, null)); } test "listRepoOps cursors are rev/idx" { @@ -1906,3 +1948,46 @@ test "listRepoOps cursors are rev/idx" { try std.testing.expect(parseOplogCursor("/4") == null); try std.testing.expect(parseOplogCursor("3lx7abcdefg22/x") == null); } + +test "space access selects independent read and write policies" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + try store.init(std.Options.debug_io, ":memory:"); + defer store.close(); + const owner = try store.createAccount(a, "access.test", "access@test.com", "password", "did:plc:access", true); + var space = try store.createSpace(a, .{ + .actor_did = owner.did, + .authority_did = owner.did, + .space_type = "test.access.space", + .skey = "self", + .is_authority = true, + .read_policy = "public", + .write_policy = "member-list", + .read_managing_app = null, + .write_managing_app = null, + .app_access_json = "{\"type\":\"open\"}", + }); + try std.testing.expect(try spacePolicyAllowsRequester(a, space, "did:plc:member", .read, null)); + try std.testing.expect(!try spacePolicyAllowsRequester(a, space, "did:plc:member", .write, null)); + space.read_policy = "member-list"; + space.write_policy = "public"; + try std.testing.expect(!try spacePolicyAllowsRequester(a, space, "did:plc:member", .read, null)); + try std.testing.expect(try spacePolicyAllowsRequester(a, space, "did:plc:member", .write, null)); + space.write_policy = "member-list"; + try store.putSimpleSpaceMember(space.uri, "did:plc:member", false, true); + try std.testing.expect(!try spacePolicyAllowsRequester(a, space, "did:plc:member", .read, null)); + try std.testing.expect(try spacePolicyAllowsRequester(a, space, "did:plc:member", .write, null)); + try std.testing.expect(try spacePolicyAllowsRequester(a, space, owner.did, .read, null)); + try std.testing.expect(try spacePolicyAllowsRequester(a, space, owner.did, .write, null)); +} + +test "managing app checks include access and omit client identity for writes" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const prefix = "https://manager.test/xrpc/com.atproto.simplespace.checkUserAccess?space=space&user=user&access="; + try std.testing.expectEqualStrings(prefix ++ "read&clientId=https%3A%2F%2Fclient.test", try managingAppAccessUrl(a, "https://manager.test", "space", "user", .read, "https://client.test")); + try std.testing.expectEqualStrings(prefix ++ "write", try managingAppAccessUrl(a, "https://manager.test", "space", "user", .write, "https://client.test")); + try std.testing.expectEqualStrings(prefix ++ "read", try managingAppAccessUrl(a, "https://manager.test", "space", "user", .read, null)); +} diff --git a/src/http/router.zig b/src/http/router.zig index 080bb42..0abe000 100644 --- a/src/http/router.zig +++ b/src/http/router.zig @@ -252,14 +252,14 @@ pub const endpoints = [_]Endpoint{ .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.getSpaceCredential", .group = "space", .auth = "experimental delegation token and DPoP proof", .summary = "Exchange a delegation token for a DPoP-bound space credential.", .body = &.{ "space", "clientAttestation" }, .notes = permissioned_data_note }, .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.notifySpaceDeleted", .group = "space", .auth = "experimental service", .summary = "Notify a repo host or syncing service that a space was deleted.", .body = &.{"space"}, .notes = permissioned_data_note }, - .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.simplespace.createSpace", .group = "simplespace", .auth = "experimental bearer", .summary = "Create a space anchored on the authenticated user's DID.", .body = &.{ "type", "skey", "policy", "appAccess" }, .notes = permissioned_data_note }, - .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.simplespace.getSpace", .group = "simplespace", .auth = "experimental OAuth bearer or DPoP space credential", .summary = "Describe a space and its policy and appAccess configuration.", .params = &.{"space"}, .notes = permissioned_data_note }, - .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.simplespace.updateSpace", .group = "simplespace", .auth = "experimental bearer", .summary = "Update a space's policy and/or appAccess.", .body = &.{ "space", "policy", "appAccess" }, .notes = permissioned_data_note }, + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.simplespace.createSpace", .group = "simplespace", .auth = "experimental bearer", .summary = "Create a space anchored on the authenticated user's DID.", .body = &.{ "type", "skey", "readPolicy", "writePolicy", "appAccess" }, .notes = permissioned_data_note }, + .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.simplespace.getSpace", .group = "simplespace", .auth = "experimental OAuth bearer or DPoP space credential", .summary = "Describe a space and its read/write policies and appAccess configuration.", .params = &.{"space"}, .notes = permissioned_data_note }, + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.simplespace.updateSpace", .group = "simplespace", .auth = "experimental bearer", .summary = "Update a space's read/write policies and/or appAccess.", .body = &.{ "space", "readPolicy", "writePolicy", "appAccess" }, .notes = permissioned_data_note }, .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.simplespace.deleteSpace", .group = "simplespace", .auth = "experimental bearer", .summary = "Delete a baseline PDS-managed space.", .body = &.{"space"}, .notes = permissioned_data_note }, - .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.simplespace.addMember", .group = "simplespace", .auth = "experimental bearer", .summary = "Add a DID to a simplespace member-list policy.", .body = &.{ "space", "did" }, .notes = permissioned_data_note }, + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.simplespace.putMember", .group = "simplespace", .auth = "experimental bearer", .summary = "Set a member's read and write access.", .body = &.{ "space", "did", "read", "write" }, .notes = permissioned_data_note }, .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.simplespace.removeMember", .group = "simplespace", .auth = "experimental bearer", .summary = "Remove a DID from a simplespace member-list policy.", .body = &.{ "space", "did" }, .notes = permissioned_data_note }, - .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.simplespace.listMembers", .group = "simplespace", .auth = "experimental bearer", .summary = "List DIDs in a simplespace member-list policy.", .params = &.{ "space", "limit", "cursor" }, .notes = permissioned_data_note }, - .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.simplespace.checkUserAccess", .group = "simplespace", .auth = "experimental service", .summary = "Ask a managing app whether a user may access a space. Generic PDS handling denies by default.", .params = &.{ "space", "user", "clientId" }, .notes = permissioned_data_note }, + .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.simplespace.listMembers", .group = "simplespace", .auth = "experimental bearer", .summary = "List members and their read and write access.", .params = &.{ "space", "limit", "cursor" }, .notes = permissioned_data_note }, + .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.simplespace.checkUserAccess", .group = "simplespace", .auth = "experimental service", .summary = "Ask a managing app whether a user may access a space. Generic PDS handling denies by default.", .params = &.{ "space", "user", "access", "clientId" }, .notes = permissioned_data_note }, }; pub fn route(method: httpz.Method, target: []const u8) Route { diff --git a/src/storage/store.zig b/src/storage/store.zig index 67469e2..bfe3d62 100644 --- a/src/storage/store.zig +++ b/src/storage/store.zig @@ -309,8 +309,10 @@ pub const SpaceConfig = struct { authority_did: []const u8, space_type: []const u8, skey: []const u8, - managing_app: ?[]const u8, - policy: []const u8, + read_managing_app: ?[]const u8, + read_policy: []const u8, + write_managing_app: ?[]const u8, + write_policy: []const u8, app_access_json: []const u8, is_authority: bool, deleted_at: ?i64, @@ -329,6 +331,8 @@ pub const OplogCursor = struct { pub const SimpleSpaceMember = struct { did: []const u8, created_at: i64, + read: bool, + write: bool, }; pub const SpaceRecord = struct { @@ -3940,8 +3944,10 @@ pub const CreateSpaceInput = struct { space_type: []const u8, skey: []const u8, is_authority: bool, - managing_app: ?[]const u8, - policy: []const u8, + read_managing_app: ?[]const u8, + read_policy: []const u8, + write_managing_app: ?[]const u8, + write_policy: []const u8, app_access_json: []const u8, }; @@ -3950,7 +3956,7 @@ pub fn createSpace(allocator: std.mem.Allocator, input: CreateSpaceInput) !Space if (zat.Did.parse(input.authority_did) == null) return Error.InvalidRepoPath; if (zat.Nsid.parse(input.space_type) == null) return Error.InvalidCollection; if (zat.Rkey.parse(input.skey) == null) return Error.InvalidRecordKey; - if (!validSimpleSpacePolicy(input.policy)) return Error.InvalidRecordType; + if (!validSimpleSpacePolicy(input.read_policy) or !validSimpleSpacePolicy(input.write_policy)) return Error.InvalidRecordType; try validateAppAccessJson(input.app_access_json); const uri = try space_uri.SpaceUri.format(allocator, input.authority_did, input.space_type, input.skey); @@ -3965,16 +3971,18 @@ pub fn createSpace(allocator: std.mem.Allocator, input: CreateSpaceInput) !Space errdefer conn.rollback(); try conn.exec( \\INSERT INTO permissioned_spaces ( - \\ uri, authority_did, space_type, skey, managing_app, policy, app_access_json - \\) VALUES (?, ?, ?, ?, ?, ?, ?) + \\ uri, authority_did, space_type, skey, read_managing_app, read_policy, write_managing_app, write_policy, app_access_json + \\) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) \\ON CONFLICT(uri) DO NOTHING , .{ uri, input.authority_did, input.space_type, input.skey, - input.managing_app, - input.policy, + input.read_managing_app, + input.read_policy, + input.write_managing_app, + input.write_policy, input.app_access_json, }); if (input.is_authority) { @@ -3983,14 +3991,18 @@ pub fn createSpace(allocator: std.mem.Allocator, input: CreateSpaceInput) !Space // space never reaches here: the guard above returns SpaceAlreadyExists. try conn.exec( \\UPDATE permissioned_spaces - \\SET managing_app = ?, - \\ policy = ?, + \\SET read_managing_app = ?, + \\ read_policy = ?, + \\ write_managing_app = ?, + \\ write_policy = ?, \\ app_access_json = ?, \\ deleted_at = NULL \\WHERE uri = ? , .{ - input.managing_app, - input.policy, + input.read_managing_app, + input.read_policy, + input.write_managing_app, + input.write_policy, input.app_access_json, uri, }); @@ -4085,36 +4097,46 @@ pub fn listSpaces( return spaces.toOwnedSlice(allocator); } +pub const SpaceAccess = enum { read, write }; + pub fn updateSimpleSpaceConfig( space: []const u8, - maybe_policy: ?SimpleSpacePolicy, + read_policy: ?SimpleSpacePolicy, + write_policy: ?SimpleSpacePolicy, maybe_app_access_json: ?[]const u8, ) !void { - if (maybe_policy) |policy| if (!validSimpleSpacePolicy(policy.policy)) return Error.InvalidRecordType; + if (read_policy) |policy| if (!validSimpleSpacePolicy(policy.policy)) return Error.InvalidRecordType; + if (write_policy) |policy| if (!validSimpleSpacePolicy(policy.policy)) return Error.InvalidRecordType; if (maybe_app_access_json) |app_access| try validateAppAccessJson(app_access); db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); _ = (try getSpaceConfigLocked(std.heap.page_allocator, space)) orelse return Error.RepoNotFound; - if (maybe_policy) |policy| { - try conn.exec("UPDATE permissioned_spaces SET policy = ?, managing_app = ? WHERE uri = ?", .{ policy.policy, policy.managing_app, space }); + try conn.exclusiveTransaction(); + errdefer conn.rollback(); + if (read_policy) |policy| { + try conn.exec("UPDATE permissioned_spaces SET read_policy = ?, read_managing_app = ? WHERE uri = ?", .{ policy.policy, policy.managing_app, space }); + } + if (write_policy) |policy| { + try conn.exec("UPDATE permissioned_spaces SET write_policy = ?, write_managing_app = ? WHERE uri = ?", .{ policy.policy, policy.managing_app, space }); } if (maybe_app_access_json) |app_access| { try conn.exec("UPDATE permissioned_spaces SET app_access_json = ? WHERE uri = ?", .{ app_access, space }); } + try conn.commit(); } -pub fn addSimpleSpaceMember(space: []const u8, did: []const u8) !void { +pub fn putSimpleSpaceMember(space: []const u8, did: []const u8, read: bool, write: bool) !void { if (zat.Did.parse(did) == null) return Error.InvalidRepoPath; db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); _ = (try getSpaceConfigLocked(std.heap.page_allocator, space)) orelse return Error.RepoNotFound; try conn.exec( - \\INSERT INTO simplespace_members (space, member_did) - \\VALUES (?, ?) - \\ON CONFLICT(space, member_did) DO NOTHING - , .{ space, did }); + \\INSERT INTO simplespace_members (space, member_did, read, write) + \\VALUES (?, ?, ?, ?) + \\ON CONFLICT(space, member_did) DO UPDATE SET read = excluded.read, write = excluded.write + , .{ space, did, @as(i64, @intFromBool(read)), @as(i64, @intFromBool(write)) }); } pub fn removeSimpleSpaceMember(space: []const u8, did: []const u8) !void { @@ -4137,10 +4159,10 @@ pub fn listSimpleSpaceMembers( defer db_mutex.unlock(store_io); try requireInitialized(); _ = (try getSpaceConfigLocked(std.heap.page_allocator, space)) orelse return Error.RepoNotFound; - const capped_limit: i64 = @intCast(@min(if (limit == 0) 50 else limit, 100)); + const capped_limit: i64 = @intCast(@min(if (limit == 0) 100 else limit, 1000)); var rows = if (maybe_cursor) |cursor| try conn.rows( - \\SELECT member_did, created_at + \\SELECT member_did, created_at, read, write \\FROM simplespace_members \\WHERE space = ? AND member_did > ? \\ORDER BY member_did ASC @@ -4148,7 +4170,7 @@ pub fn listSimpleSpaceMembers( , .{ space, cursor, capped_limit }) else try conn.rows( - \\SELECT member_did, created_at + \\SELECT member_did, created_at, read, write \\FROM simplespace_members \\WHERE space = ? \\ORDER BY member_did ASC @@ -4160,23 +4182,25 @@ pub fn listSimpleSpaceMembers( try members.append(allocator, .{ .did = try allocator.dupe(u8, row.text(0)), .created_at = row.int(1), + .read = row.int(2) != 0, + .write = row.int(3) != 0, }); } if (rows.err) |err| return err; return members.toOwnedSlice(allocator); } -pub fn simpleSpaceHasMember(space: []const u8, did: []const u8) !bool { +pub fn simpleSpaceMemberAllows(space: []const u8, did: []const u8, access: SpaceAccess) !bool { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const row = try conn.row( - "SELECT 1 FROM simplespace_members WHERE space = ? AND member_did = ?", + "SELECT read, write FROM simplespace_members WHERE space = ? AND member_did = ?", .{ space, did }, ); if (row == null) return false; defer row.?.deinit(); - return true; + return row.?.int(if (access == .read) 0 else 1) != 0; } pub fn listSpaceWriters(allocator: std.mem.Allocator, space: []const u8, maybe_cursor: ?[]const u8, limit: usize) ![]SpaceWriterState { @@ -4776,12 +4800,12 @@ fn insertRecordOplogLocked( fn getSpaceConfigLocked(allocator: std.mem.Allocator, uri: []const u8) !?SpaceConfig { const row = try conn.row( - \\SELECT s.uri, s.authority_did, s.space_type, s.skey, s.managing_app, s.policy, s.app_access_json, + \\SELECT s.uri, s.authority_did, s.space_type, s.skey, s.read_managing_app, s.read_policy, s.app_access_json, \\ EXISTS ( \\ SELECT 1 FROM permissioned_space_actor_state a \\ WHERE a.space = s.uri AND a.actor_did = s.authority_did \\ AND a.is_authority = 1 AND a.deleted_at IS NULL - \\ ) + \\ ), s.write_managing_app, s.write_policy \\FROM permissioned_spaces s \\WHERE s.uri = ? AND s.deleted_at IS NULL , .{uri}); @@ -4793,18 +4817,20 @@ fn getSpaceConfigLocked(allocator: std.mem.Allocator, uri: []const u8) !?SpaceCo .authority_did = try allocator.dupe(u8, row.?.text(1)), .space_type = try allocator.dupe(u8, row.?.text(2)), .skey = try allocator.dupe(u8, row.?.text(3)), - .managing_app = if (row.?.nullableText(4)) |value| try allocator.dupe(u8, value) else null, - .policy = try allocator.dupe(u8, row.?.text(5)), + .read_managing_app = if (row.?.nullableText(4)) |value| try allocator.dupe(u8, value) else null, + .read_policy = try allocator.dupe(u8, row.?.text(5)), .app_access_json = try allocator.dupe(u8, row.?.text(6)), .is_authority = row.?.int(7) != 0, + .write_managing_app = if (row.?.nullableText(8)) |value| try allocator.dupe(u8, value) else null, + .write_policy = try allocator.dupe(u8, row.?.text(9)), .deleted_at = null, }; } fn getSpaceLocked(allocator: std.mem.Allocator, actor_did: []const u8, uri: []const u8) !?SpaceConfig { const row = try conn.row( - \\SELECT s.uri, s.authority_did, s.space_type, s.skey, s.managing_app, s.policy, s.app_access_json, - \\ a.is_authority, a.deleted_at + \\SELECT s.uri, s.authority_did, s.space_type, s.skey, s.read_managing_app, s.read_policy, s.app_access_json, + \\ a.is_authority, a.deleted_at, s.write_managing_app, s.write_policy \\FROM permissioned_spaces s \\JOIN permissioned_space_actor_state a ON a.space = s.uri AND a.actor_did = ? \\WHERE s.uri = ? AND s.deleted_at IS NULL AND a.deleted_at IS NULL @@ -4817,11 +4843,13 @@ fn getSpaceLocked(allocator: std.mem.Allocator, actor_did: []const u8, uri: []co .authority_did = try allocator.dupe(u8, row.?.text(1)), .space_type = try allocator.dupe(u8, row.?.text(2)), .skey = try allocator.dupe(u8, row.?.text(3)), - .managing_app = if (row.?.nullableText(4)) |value| try allocator.dupe(u8, value) else null, - .policy = try allocator.dupe(u8, row.?.text(5)), + .read_managing_app = if (row.?.nullableText(4)) |value| try allocator.dupe(u8, value) else null, + .read_policy = try allocator.dupe(u8, row.?.text(5)), .app_access_json = try allocator.dupe(u8, row.?.text(6)), .is_authority = row.?.int(7) != 0, .deleted_at = if (row.?.nullableInt(8)) |value| value else null, + .write_managing_app = if (row.?.nullableText(9)) |value| try allocator.dupe(u8, value) else null, + .write_policy = try allocator.dupe(u8, row.?.text(10)), }; } @@ -5156,26 +5184,25 @@ fn migratePermissionedSpaceWriters() !void { } fn migrateSimpleSpaceConfig() !void { - conn.execNoArgs("ALTER TABLE permissioned_spaces ADD COLUMN policy TEXT NOT NULL DEFAULT 'member-list'") catch {}; - conn.execNoArgs("ALTER TABLE permissioned_spaces ADD COLUMN app_access_json TEXT NOT NULL DEFAULT '{\"type\":\"open\"}'") catch {}; - conn.execNoArgs( - \\UPDATE permissioned_spaces - \\SET policy = CASE WHEN is_public = 1 THEN 'public' ELSE 'member-list' END - \\WHERE EXISTS ( - \\ SELECT 1 FROM pragma_table_info('permissioned_spaces') WHERE name = 'is_public' - \\) - ) catch {}; - conn.execNoArgs( - \\UPDATE permissioned_spaces - \\SET app_access_json = CASE - \\ WHEN app_access_mode = 'deny' AND app_exceptions_json != '[]' - \\ THEN '{"type":"allowList","allowed":[]}' - \\ ELSE '{"type":"open"}' - \\END - \\WHERE EXISTS ( - \\ SELECT 1 FROM pragma_table_info('permissioned_spaces') WHERE name = 'app_access_mode' - \\) - ) catch {}; + if (try permissionedSpacesColumnExistsLocked("read_policy")) return; + try conn.exclusiveTransaction(); + errdefer conn.rollback(); + if (!try permissionedSpacesColumnExistsLocked("policy")) { + try conn.execNoArgs("ALTER TABLE permissioned_spaces ADD COLUMN policy TEXT NOT NULL DEFAULT 'member-list'"); + if (try permissionedSpacesColumnExistsLocked("is_public")) { + try conn.execNoArgs("UPDATE permissioned_spaces SET policy = CASE WHEN is_public = 1 THEN 'public' ELSE 'member-list' END"); + } + } + if (!try permissionedSpacesColumnExistsLocked("app_access_json")) { + try conn.execNoArgs("ALTER TABLE permissioned_spaces ADD COLUMN app_access_json TEXT NOT NULL DEFAULT '{\"type\":\"open\"}'"); + if (try permissionedSpacesColumnExistsLocked("app_access_mode")) { + try conn.execNoArgs( + \\UPDATE permissioned_spaces SET app_access_json = CASE + \\WHEN app_access_mode = 'deny' AND app_exceptions_json != '[]' + \\THEN '{"type":"allowList","allowed":[]}' ELSE '{"type":"open"}' END + ); + } + } try conn.execNoArgs( \\CREATE TABLE IF NOT EXISTS simplespace_members ( \\ space TEXT NOT NULL REFERENCES permissioned_spaces(uri) ON DELETE CASCADE, @@ -5190,6 +5217,14 @@ fn migrateSimpleSpaceConfig() !void { \\SELECT uri, authority_did \\FROM permissioned_spaces ); + try conn.execNoArgs("ALTER TABLE permissioned_spaces RENAME COLUMN policy TO read_policy"); + try conn.execNoArgs("ALTER TABLE permissioned_spaces RENAME COLUMN managing_app TO read_managing_app"); + try conn.execNoArgs("ALTER TABLE permissioned_spaces ADD COLUMN write_policy TEXT NOT NULL DEFAULT 'member-list'"); + try conn.execNoArgs("ALTER TABLE permissioned_spaces ADD COLUMN write_managing_app TEXT"); + try conn.execNoArgs("UPDATE permissioned_spaces SET write_policy = read_policy, write_managing_app = read_managing_app"); + try conn.execNoArgs("ALTER TABLE simplespace_members ADD COLUMN read INTEGER NOT NULL DEFAULT 1"); + try conn.execNoArgs("ALTER TABLE simplespace_members ADD COLUMN write INTEGER NOT NULL DEFAULT 1"); + try conn.commit(); } fn ensureMigrationTable() !void { @@ -6990,8 +7025,10 @@ const schema_statements = [_][*:0]const u8{ \\ authority_did TEXT NOT NULL, \\ space_type TEXT NOT NULL, \\ skey TEXT NOT NULL, - \\ managing_app TEXT, - \\ policy TEXT NOT NULL DEFAULT 'member-list', + \\ read_managing_app TEXT, + \\ read_policy TEXT NOT NULL DEFAULT 'member-list', + \\ write_managing_app TEXT, + \\ write_policy TEXT NOT NULL DEFAULT 'member-list', \\ app_access_json TEXT NOT NULL DEFAULT '{"type":"open"}', \\ is_authority INTEGER NOT NULL DEFAULT 1, \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()), @@ -7011,6 +7048,8 @@ const schema_statements = [_][*:0]const u8{ \\CREATE TABLE IF NOT EXISTS simplespace_members ( \\ space TEXT NOT NULL REFERENCES permissioned_spaces(uri) ON DELETE CASCADE, \\ member_did TEXT NOT NULL, + \\ read INTEGER NOT NULL DEFAULT 1, + \\ write INTEGER NOT NULL DEFAULT 1, \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()), \\ PRIMARY KEY (space, member_did) \\) @@ -7722,8 +7761,10 @@ test "permissioned spaces store self-owned records outside public repo" { .space_type = "fm.plyr.privateMedia", .skey = "self", .is_authority = true, - .managing_app = "did:web:plyr.fm", - .policy = "member-list", + .read_managing_app = "did:web:plyr.fm", + .read_policy = "member-list", + .write_managing_app = "did:web:plyr.fm", + .write_policy = "member-list", .app_access_json = "{\"type\":\"open\"}", }); try std.testing.expectEqualStrings("at://did:plc:spaceauthorityalice/space/fm.plyr.privateMedia/self", space.uri); @@ -7735,8 +7776,10 @@ test "permissioned spaces store self-owned records outside public repo" { .space_type = "fm.plyr.privateMedia", .skey = "self", .is_authority = true, - .managing_app = null, - .policy = "member-list", + .read_managing_app = null, + .read_policy = "member-list", + .write_managing_app = null, + .write_policy = "member-list", .app_access_json = "{\"type\":\"open\"}", })); @@ -7805,8 +7848,10 @@ test "permissioned blob ref migration backfills existing records" { .space_type = "fm.example.private", .skey = "self", .is_authority = true, - .managing_app = null, - .policy = "member-list", + .read_managing_app = null, + .read_policy = "member-list", + .write_managing_app = null, + .write_policy = "member-list", .app_access_json = "{\"type\":\"open\"}", }); const parsed = try std.json.parseFromSlice( @@ -7842,8 +7887,10 @@ test "permissioned space foundation migration preserves rows and rebuilds hashes .space_type = "fm.example.private", .skey = "self", .is_authority = true, - .managing_app = null, - .policy = "member-list", + .read_managing_app = null, + .read_policy = "member-list", + .write_managing_app = null, + .write_policy = "member-list", .app_access_json = "{\"type\":\"open\"}", }); try std.testing.expectEqualStrings(canonical, space.uri); @@ -8012,8 +8059,10 @@ test "permissioned spaces keep authority-local state per space URI" { .space_type = "fm.plyr.privateMedia", .skey = "self", .is_authority = true, - .managing_app = null, - .policy = "member-list", + .read_managing_app = null, + .read_policy = "member-list", + .write_managing_app = null, + .write_policy = "member-list", .app_access_json = "{\"type\":\"open\"}", }); @@ -8047,8 +8096,10 @@ test "permissioned space create is duplicate-checked per actor" { .space_type = "fm.plyr.privateMedia", .skey = "self", .is_authority = false, - .managing_app = null, - .policy = "member-list", + .read_managing_app = null, + .read_policy = "member-list", + .write_managing_app = null, + .write_policy = "member-list", .app_access_json = "{\"type\":\"open\"}", }); try std.testing.expect(!viewer_row.is_authority); @@ -8059,15 +8110,17 @@ test "permissioned space create is duplicate-checked per actor" { .space_type = "fm.plyr.privateMedia", .skey = "self", .is_authority = true, - .managing_app = "did:web:plyr.fm", - .policy = "public", + .read_managing_app = "did:web:plyr.fm", + .read_policy = "public", + .write_managing_app = "did:web:plyr.fm", + .write_policy = "public", .app_access_json = "{\"type\":\"allowList\",\"allowed\":[\"did:web:allowed.example\"]}", }); try std.testing.expect(owner_row.is_authority); - try std.testing.expectEqualStrings("public", owner_row.policy); - try std.testing.expectEqualStrings("did:web:plyr.fm", owner_row.managing_app.?); + try std.testing.expectEqualStrings("public", owner_row.read_policy); + try std.testing.expectEqualStrings("did:web:plyr.fm", owner_row.read_managing_app.?); - try addSimpleSpaceMember(owner_row.uri, member.did); + try putSimpleSpaceMember(owner_row.uri, member.did, true, true); const member_spaces = try listSpaces(allocator, member.did, owner.did, "fm.plyr.privateMedia", null, 50); try std.testing.expectEqual(@as(usize, 0), member_spaces.len); @@ -8081,8 +8134,10 @@ test "permissioned space create is duplicate-checked per actor" { .space_type = "fm.plyr.privateMedia", .skey = "self", .is_authority = false, - .managing_app = null, - .policy = "member-list", + .read_managing_app = null, + .read_policy = "member-list", + .write_managing_app = null, + .write_policy = "member-list", .app_access_json = "{\"type\":\"open\"}", })); } @@ -8335,8 +8390,10 @@ test "blob gc follows permissioned record references" { .space_type = "fm.example.private", .skey = "self", .is_authority = true, - .managing_app = null, - .policy = "member-list", + .read_managing_app = null, + .read_policy = "member-list", + .write_managing_app = null, + .write_policy = "member-list", .app_access_json = "{\"type\":\"open\"}", }); const json = try std.fmt.allocPrint( @@ -8378,8 +8435,10 @@ test "blob visibility follows public and permissioned references independently" .space_type = "fm.example.private", .skey = "self", .is_authority = true, - .managing_app = null, - .policy = "member-list", + .read_managing_app = null, + .read_policy = "member-list", + .write_managing_app = null, + .write_policy = "member-list", .app_access_json = "{\"type\":\"open\"}", }); const json = try std.fmt.allocPrint( @@ -9227,3 +9286,89 @@ test "account access groups count beyond pages and isolate detail queries" { try std.testing.expectEqual(@as(usize, 1), details.sessions.len); try std.testing.expectEqualStrings(owner.did, details.sessions[0].did); } + +test "simplespace migration preserves policies and members and does not reset new permissions" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + try init(std.Options.debug_io, ":memory:"); + defer close(); + const owner = try createAccount(a, "migration.test", "migration@test.com", "password", "did:plc:migration", true); + for ([_][]const u8{ "public", "member-list", "managing-app" }) |policy| { + const space = try createSpace(a, .{ + .actor_did = owner.did, + .authority_did = owner.did, + .space_type = "test.migration.space", + .skey = policy, + .is_authority = true, + .read_policy = policy, + .write_policy = policy, + .read_managing_app = "did:web:manager.test#service", + .write_managing_app = "did:web:manager.test#service", + .app_access_json = "{\"type\":\"allowList\",\"allowed\":[\"https://client.test\"]}", + }); + try putSimpleSpaceMember(space.uri, "did:plc:member", true, true); + } + // Recreate the previous schema, including older columns that must not + // overwrite a subsequently configured policy during the upgrade. + try conn.execNoArgs("ALTER TABLE permissioned_spaces DROP COLUMN write_policy"); + try conn.execNoArgs("ALTER TABLE permissioned_spaces DROP COLUMN write_managing_app"); + try conn.execNoArgs("ALTER TABLE permissioned_spaces RENAME COLUMN read_policy TO policy"); + try conn.execNoArgs("ALTER TABLE permissioned_spaces RENAME COLUMN read_managing_app TO managing_app"); + try conn.execNoArgs("ALTER TABLE permissioned_spaces ADD COLUMN is_public INTEGER NOT NULL DEFAULT 0"); + try conn.execNoArgs("ALTER TABLE simplespace_members DROP COLUMN read"); + try conn.execNoArgs("ALTER TABLE simplespace_members DROP COLUMN write"); + try migrateSimpleSpaceConfig(); + for ([_][]const u8{ "public", "member-list", "managing-app" }) |policy| { + const uri = try std.fmt.allocPrint(a, "at://{s}/space/test.migration.space/{s}", .{ owner.did, policy }); + const space = (try getSpace(a, owner.did, uri)).?; + try std.testing.expectEqualStrings(policy, space.read_policy); + try std.testing.expectEqualStrings(policy, space.write_policy); + try std.testing.expectEqualStrings(space.read_managing_app.?, space.write_managing_app.?); + try std.testing.expectEqualStrings("{\"type\":\"allowList\",\"allowed\":[\"https://client.test\"]}", space.app_access_json); + const members = try listSimpleSpaceMembers(a, uri, null, 100); + try std.testing.expectEqual(@as(usize, 2), members.len); + for (members) |member| try std.testing.expect(member.read and member.write); + try putSimpleSpaceMember(uri, "did:plc:member", true, false); + try updateSimpleSpaceConfig(uri, null, .{ .policy = "public", .managing_app = null }, null); + } + try migrateSimpleSpaceConfig(); + const uri = "at://did:plc:migration/space/test.migration.space/member-list"; + try std.testing.expect(!try simpleSpaceMemberAllows(uri, "did:plc:member", .write)); + try std.testing.expect(try simpleSpaceMemberAllows(uri, "did:plc:member", .read)); + const updated = (try getSpace(a, owner.did, uri)).?; + try std.testing.expectEqualStrings("member-list", updated.read_policy); + try std.testing.expectEqualStrings("public", updated.write_policy); + try std.testing.expect(updated.write_managing_app == null); + try std.testing.expect(!try permissionedSpacesColumnExistsLocked("policy")); +} + +test "simplespace member upserts replace access in both directions" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + try init(std.Options.debug_io, ":memory:"); + defer close(); + const owner = try createAccount(a, "members.test", "members@test.com", "password", "did:plc:members", true); + const space = try createSpace(a, .{ + .actor_did = owner.did, + .authority_did = owner.did, + .space_type = "test.members.space", + .skey = "self", + .is_authority = true, + .read_policy = "member-list", + .write_policy = "member-list", + .read_managing_app = null, + .write_managing_app = null, + .app_access_json = "{\"type\":\"open\"}", + }); + for ([_][2]bool{ .{ true, false }, .{ false, true }, .{ false, false }, .{ true, true } }) |access| { + try putSimpleSpaceMember(space.uri, "did:plc:member", access[0], access[1]); + try std.testing.expectEqual(access[0], try simpleSpaceMemberAllows(space.uri, "did:plc:member", .read)); + try std.testing.expectEqual(access[1], try simpleSpaceMemberAllows(space.uri, "did:plc:member", .write)); + try std.testing.expectEqual(@as(usize, 2), (try listSimpleSpaceMembers(a, space.uri, null, 100)).len); + } + try removeSimpleSpaceMember(space.uri, "did:plc:member"); + try std.testing.expect(!try simpleSpaceMemberAllows(space.uri, "did:plc:member", .read)); + try std.testing.expect(!try simpleSpaceMemberAllows(space.uri, "did:plc:member", .write)); +} diff --git a/tools/smoke-permissioned.sh b/tools/smoke-permissioned.sh index 19dc033..09313f1 100755 --- a/tools/smoke-permissioned.sh +++ b/tools/smoke-permissioned.sh @@ -113,7 +113,7 @@ test "$public_blob_status" = "404" space_create=$(curl -fsS -X POST "$base/xrpc/com.atproto.simplespace.createSpace" \ -H "authorization: Bearer $token" \ -H 'content-type: application/json' \ - --data '{"type":"fm.plyr.privateMedia","skey":"self","policy":{"$type":"com.atproto.simplespace.defs#managingAppPolicy","managingApp":"did:web:plyr.fm"},"appAccess":{"$type":"com.atproto.simplespace.defs#open"}}') + --data '{"type":"fm.plyr.privateMedia","skey":"self","readPolicy":{"$type":"com.atproto.simplespace.defs#managingAppPolicy","managingApp":"did:web:plyr.fm"},"writePolicy":{"$type":"com.atproto.simplespace.defs#managingAppPolicy","managingApp":"did:web:plyr.fm"},"appAccess":{"$type":"com.atproto.simplespace.defs#open"}}') test "$space_create" = '{"uri":"at://did:plc:permissionsmoke/space/fm.plyr.privateMedia/self"}' # the pre-alpha body shape (did + config wrapper) is rejected, not silently accepted @@ -127,27 +127,27 @@ grep -q '"error":"InvalidRequest"' /tmp/zds-space-legacy-create.json unsupported_policy_status=$(curl -sS -o /tmp/zds-space-unsupported-policy.json -w '%{http_code}' -X POST "$base/xrpc/com.atproto.simplespace.createSpace" \ -H "authorization: Bearer $token" \ -H 'content-type: application/json' \ - --data '{"type":"fm.plyr.privateMedia","skey":"unsupported","policy":{"$type":"com.atproto.simplespace.defs#managingAppPolicy","managingApp":"https://plyr.fm"},"appAccess":{"$type":"com.atproto.simplespace.defs#open"}}') + --data '{"type":"fm.plyr.privateMedia","skey":"unsupported","readPolicy":{"$type":"com.atproto.simplespace.defs#managingAppPolicy","managingApp":"https://plyr.fm"},"writePolicy":{"$type":"com.atproto.simplespace.defs#managingAppPolicy","managingApp":"https://plyr.fm"},"appAccess":{"$type":"com.atproto.simplespace.defs#open"}}') test "$unsupported_policy_status" = "400" grep -q '"error":"UnsupportedPolicy"' /tmp/zds-space-unsupported-policy.json unsupported_app_access_status=$(curl -sS -o /tmp/zds-space-unsupported-app-access.json -w '%{http_code}' -X POST "$base/xrpc/com.atproto.simplespace.createSpace" \ -H "authorization: Bearer $token" \ -H 'content-type: application/json' \ - --data '{"type":"fm.plyr.privateMedia","skey":"unsupported","policy":{"$type":"com.atproto.simplespace.defs#publicPolicy"},"appAccess":{"$type":"com.atproto.simplespace.defs#denyList"}}') + --data '{"type":"fm.plyr.privateMedia","skey":"unsupported","readPolicy":{"$type":"com.atproto.simplespace.defs#publicPolicy"},"writePolicy":{"$type":"com.atproto.simplespace.defs#publicPolicy"},"appAccess":{"$type":"com.atproto.simplespace.defs#denyList"}}') test "$unsupported_app_access_status" = "400" grep -q '"error":"UnsupportedAppAccess"' /tmp/zds-space-unsupported-app-access.json curl -fsS -X POST "$base/xrpc/com.atproto.simplespace.createSpace" \ -H "authorization: Bearer $token" \ -H 'content-type: application/json' \ - --data '{"type":"fm.plyr.privateMedia","skey":"other","policy":{"$type":"com.atproto.simplespace.defs#memberListPolicy"},"appAccess":{"$type":"com.atproto.simplespace.defs#open"}}' \ + --data '{"type":"fm.plyr.privateMedia","skey":"other","readPolicy":{"$type":"com.atproto.simplespace.defs#memberListPolicy"},"writePolicy":{"$type":"com.atproto.simplespace.defs#memberListPolicy"},"appAccess":{"$type":"com.atproto.simplespace.defs#open"}}' \ | grep -q '"uri":"at://did:plc:permissionsmoke/space/fm.plyr.privateMedia/other"' space_duplicate_status=$(curl -sS -o /tmp/zds-space-duplicate.json -w '%{http_code}' -X POST "$base/xrpc/com.atproto.simplespace.createSpace" \ -H "authorization: Bearer $token" \ -H 'content-type: application/json' \ - --data '{"type":"fm.plyr.privateMedia","skey":"self","policy":{"$type":"com.atproto.simplespace.defs#memberListPolicy"},"appAccess":{"$type":"com.atproto.simplespace.defs#open"}}') + --data '{"type":"fm.plyr.privateMedia","skey":"self","readPolicy":{"$type":"com.atproto.simplespace.defs#memberListPolicy"},"writePolicy":{"$type":"com.atproto.simplespace.defs#memberListPolicy"},"appAccess":{"$type":"com.atproto.simplespace.defs#open"}}') test "$space_duplicate_status" = "400" grep -q '"error":"SpaceAlreadyExists"' /tmp/zds-space-duplicate.json @@ -157,17 +157,17 @@ recreate_space_uri="at://did:plc:permissionsmoke/space/fm.plyr.privateMedia/recr encoded_recreate_space=$(printf '%s' "$recreate_space_uri" | jq -sRr @uri) curl -fsS -X POST "$base/xrpc/com.atproto.simplespace.createSpace" \ -H "authorization: Bearer $token" -H 'content-type: application/json' \ - --data '{"type":"fm.plyr.privateMedia","skey":"recreate","policy":{"$type":"com.atproto.simplespace.defs#publicPolicy"},"appAccess":{"$type":"com.atproto.simplespace.defs#open"}}' \ + --data '{"type":"fm.plyr.privateMedia","skey":"recreate","readPolicy":{"$type":"com.atproto.simplespace.defs#publicPolicy"},"writePolicy":{"$type":"com.atproto.simplespace.defs#publicPolicy"},"appAccess":{"$type":"com.atproto.simplespace.defs#open"}}' \ | grep -q '"uri":"'"$recreate_space_uri"'"' curl -fsS -X POST "$base/xrpc/com.atproto.simplespace.deleteSpace" \ -H "authorization: Bearer $token" -H 'content-type: application/json' \ --data "$(jq -nc --arg space "$recreate_space_uri" '{space:$space}')" | grep -q '{}' recreate_after_delete=$(curl -fsS -X POST "$base/xrpc/com.atproto.simplespace.createSpace" \ -H "authorization: Bearer $token" -H 'content-type: application/json' \ - --data '{"type":"fm.plyr.privateMedia","skey":"recreate","policy":{"$type":"com.atproto.simplespace.defs#managingAppPolicy","managingApp":"did:web:plyr.fm"},"appAccess":{"$type":"com.atproto.simplespace.defs#open"}}') + --data '{"type":"fm.plyr.privateMedia","skey":"recreate","readPolicy":{"$type":"com.atproto.simplespace.defs#managingAppPolicy","managingApp":"did:web:plyr.fm"},"writePolicy":{"$type":"com.atproto.simplespace.defs#managingAppPolicy","managingApp":"did:web:plyr.fm"},"appAccess":{"$type":"com.atproto.simplespace.defs#open"}}') test "$recreate_after_delete" = '{"uri":"'"$recreate_space_uri"'"}' recreate_get=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/com.atproto.simplespace.getSpace?space=$encoded_recreate_space") -test "$recreate_get" = '{"uri":"'"$recreate_space_uri"'","policy":{"$type":"com.atproto.simplespace.defs#managingAppPolicy","managingApp":"did:web:plyr.fm"},"appAccess":{"$type":"com.atproto.simplespace.defs#open"}}' +test "$recreate_get" = '{"uri":"'"$recreate_space_uri"'","readPolicy":{"$type":"com.atproto.simplespace.defs#managingAppPolicy","managingApp":"did:web:plyr.fm"},"writePolicy":{"$type":"com.atproto.simplespace.defs#managingAppPolicy","managingApp":"did:web:plyr.fm"},"appAccess":{"$type":"com.atproto.simplespace.defs#open"}}' # ---- deletion purges the authority-hosted state, and a non-member on this # PDS cannot write into a locally hosted member-list space ---- @@ -182,7 +182,7 @@ test -n "$bob_token" curl -fsS -X POST "$base/xrpc/com.atproto.simplespace.createSpace" \ -H "authorization: Bearer $token" -H 'content-type: application/json' \ - --data '{"type":"fm.plyr.privateMedia","skey":"purge","policy":{"$type":"com.atproto.simplespace.defs#memberListPolicy"},"appAccess":{"$type":"com.atproto.simplespace.defs#open"}}' \ + --data '{"type":"fm.plyr.privateMedia","skey":"purge","readPolicy":{"$type":"com.atproto.simplespace.defs#memberListPolicy"},"writePolicy":{"$type":"com.atproto.simplespace.defs#memberListPolicy"},"appAccess":{"$type":"com.atproto.simplespace.defs#open"}}' \ | grep -q '"uri":"'"$purge_space_uri"'"' # not on the list: the write is refused before anything is materialized @@ -194,9 +194,9 @@ grep -q '"error":"NotPermitted"' /tmp/zds-space-bob-write.json test "$(sqlite3 "$db" "select count(*) from permissioned_space_records where space = '$purge_space_uri'")" = "0" # added to the list: the same write lands -curl -fsS -X POST "$base/xrpc/com.atproto.simplespace.addMember" \ +curl -fsS -X POST "$base/xrpc/com.atproto.simplespace.putMember" \ -H "authorization: Bearer $token" -H 'content-type: application/json' \ - --data "$(jq -nc --arg space "$purge_space_uri" '{space:$space,did:"did:plc:spacesmokebob"}')" | grep -q '{}' + --data "$(jq -nc --arg space "$purge_space_uri" '{space:$space,did:"did:plc:spacesmokebob",read:true,write:true}')" | grep -q '{}' curl -fsS -X POST "$base/xrpc/com.atproto.space.createRecord" \ -H "authorization: Bearer $bob_token" -H 'content-type: application/json' \ --data "$(jq -nc --arg space "$purge_space_uri" '{space:$space,repo:"did:plc:spacesmokebob",collection:"fm.plyr.track",rkey:"bob-one",record:{"$type":"fm.plyr.track",title:"allowed now"}}')" \ @@ -219,7 +219,7 @@ test "$(sqlite3 "$db" "select count(*) from permissioned_space_records where spa # recreated, the space starts with an empty member list — nothing resurrects curl -fsS -X POST "$base/xrpc/com.atproto.simplespace.createSpace" \ -H "authorization: Bearer $token" -H 'content-type: application/json' \ - --data '{"type":"fm.plyr.privateMedia","skey":"purge","policy":{"$type":"com.atproto.simplespace.defs#memberListPolicy"},"appAccess":{"$type":"com.atproto.simplespace.defs#open"}}' \ + --data '{"type":"fm.plyr.privateMedia","skey":"purge","readPolicy":{"$type":"com.atproto.simplespace.defs#memberListPolicy"},"writePolicy":{"$type":"com.atproto.simplespace.defs#memberListPolicy"},"appAccess":{"$type":"com.atproto.simplespace.defs#open"}}' \ | grep -q '"uri":"'"$purge_space_uri"'"' bob_write_again_status=$(curl -sS -o /tmp/zds-space-bob-write-again.json -w '%{http_code}' -X POST "$base/xrpc/com.atproto.space.createRecord" \ -H "authorization: Bearer $bob_token" -H 'content-type: application/json' \ @@ -228,16 +228,16 @@ test "$bob_write_again_status" = "403" grep -q '"error":"NotPermitted"' /tmp/zds-space-bob-write-again.json space_get=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/com.atproto.simplespace.getSpace?space=$encoded_space") -test "$space_get" = '{"uri":"at://did:plc:permissionsmoke/space/fm.plyr.privateMedia/self","policy":{"$type":"com.atproto.simplespace.defs#managingAppPolicy","managingApp":"did:web:plyr.fm"},"appAccess":{"$type":"com.atproto.simplespace.defs#open"}}' +test "$space_get" = '{"uri":"at://did:plc:permissionsmoke/space/fm.plyr.privateMedia/self","readPolicy":{"$type":"com.atproto.simplespace.defs#managingAppPolicy","managingApp":"did:web:plyr.fm"},"writePolicy":{"$type":"com.atproto.simplespace.defs#managingAppPolicy","managingApp":"did:web:plyr.fm"},"appAccess":{"$type":"com.atproto.simplespace.defs#open"}}' # switching policy drops the managing app; the pre-alpha route name answers identically curl -fsS -X POST "$base/xrpc/com.atproto.simplespace.updateSpace" \ -H "authorization: Bearer $token" \ -H 'content-type: application/json' \ - --data "$(jq -nc --arg space "$space_uri" '{space:$space,policy:{"$type":"com.atproto.simplespace.defs#memberListPolicy"}}')" \ + --data "$(jq -nc --arg space "$space_uri" '{space:$space,readPolicy:{"$type":"com.atproto.simplespace.defs#memberListPolicy"},writePolicy:{"$type":"com.atproto.simplespace.defs#memberListPolicy"}}')" \ | grep -q '{}' space_get_updated=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/com.atproto.space.getSpace?space=$encoded_space") -test "$space_get_updated" = '{"uri":"at://did:plc:permissionsmoke/space/fm.plyr.privateMedia/self","policy":{"$type":"com.atproto.simplespace.defs#memberListPolicy"},"appAccess":{"$type":"com.atproto.simplespace.defs#open"}}' +test "$space_get_updated" = '{"uri":"at://did:plc:permissionsmoke/space/fm.plyr.privateMedia/self","readPolicy":{"$type":"com.atproto.simplespace.defs#memberListPolicy"},"writePolicy":{"$type":"com.atproto.simplespace.defs#memberListPolicy"},"appAccess":{"$type":"com.atproto.simplespace.defs#open"}}' delegation=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/com.atproto.space.getDelegationToken?space=$encoded_space" | jq -r '.token') test -n "$delegation" @@ -394,4 +394,6 @@ other_space_blob_status=$(curl -sS -o /tmp/zds-space-other-blob.json -w '%{http_ test "$other_space_blob_status" = "404" grep -q '"error":"BlobNotFound"' /tmp/zds-space-other-blob.json +SMOKE_BASE="$base" SMOKE_TOKEN="$token" SMOKE_BOB_TOKEN="$bob_token" python3 tools/smoke-space-access.py + echo "zds permissioned smoke ok" diff --git a/tools/smoke-space-access.py b/tools/smoke-space-access.py new file mode 100644 index 0000000..1b4b2e1 --- /dev/null +++ b/tools/smoke-space-access.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Exercise independent space access using the disposable smoke server/accounts.""" +import json +import os +import subprocess +import urllib.error +import urllib.parse +import urllib.request + +base = os.environ['SMOKE_BASE'] +owner = os.environ['SMOKE_TOKEN'] +member = os.environ['SMOKE_BOB_TOKEN'] +member_did = 'did:plc:spacesmokebob' +member_policy = {'$type': 'com.atproto.simplespace.defs#memberListPolicy'} +public_policy = {'$type': 'com.atproto.simplespace.defs#publicPolicy'} + + +def call(method, body=None, token=owner, params=None, headers=None): + url = base + '/xrpc/' + method + if params: + url += '?' + urllib.parse.urlencode(params) + request = urllib.request.Request(url, data=json.dumps(body).encode() if body is not None else None, + headers={'authorization': 'Bearer ' + token, 'content-type': 'application/json', **(headers or {})}) + try: + response = urllib.request.urlopen(request, timeout=10) + except urllib.error.HTTPError as error: + response = error + with response: + return response.status, json.load(response) + + +def expect(method, expected, **kwargs): + status, body = call(method, **kwargs) + assert status == expected, f'{method}: expected {expected}, got {status} ({body.get("error")})' + return body + + +create = {'type': 'test.access.space', 'skey': 'self', 'readPolicy': member_policy, + 'writePolicy': member_policy, 'appAccess': {'$type': 'com.atproto.simplespace.defs#open'}} +space = expect('com.atproto.simplespace.createSpace', 200, body=create)['uri'] +for obsolete in ({'policy': member_policy}, {**create, 'policy': member_policy}): + expect('com.atproto.simplespace.createSpace', 400, body={**create, **obsolete}) +expect('com.atproto.simplespace.updateSpace', 400, body={'space': space, 'policy': public_policy}) +expect('com.atproto.simplespace.addMember', 404, body={'space': space, 'did': member_did}) +for fields in ({'read': True}, {'read': 'true', 'write': True}, {'read': True, 'write': None}): + expect('com.atproto.simplespace.putMember', 400, body={'space': space, 'did': member_did, **fields}) +expect('com.atproto.simplespace.putMember', 403, token=member, + body={'space': space, 'did': member_did, 'read': True, 'write': True}) +expect('com.atproto.simplespace.listMembers', 403, token=member, params={'space': space}) + +for index, (read, write) in enumerate(((True, False), (False, True), (False, False), (True, True))): + expect('com.atproto.simplespace.putMember', 200, + body={'space': space, 'did': member_did, 'read': read, 'write': write}) + members = expect('com.atproto.simplespace.listMembers', 200, params={'space': space})['members'] + assert len(members) == 2 + assert next(m for m in members if m['did'] == member_did) == {'did': member_did, 'read': read, 'write': write} + expect('com.atproto.space.createRecord', 200 if write else 403, token=member, + body={'space': space, 'repo': member_did, 'collection': 'test.access.record', + 'rkey': f'case-{index}', 'record': {'$type': 'test.access.record', 'text': 'disposable'}}) + delegation = expect('com.atproto.space.getDelegationToken', 200, token=member, params={'space': space})['token'] + proof = subprocess.check_output(['./zig-out/bin/zds-space-dpop', 'POST', base + '/xrpc/com.atproto.space.getSpaceCredential'], text=True, stderr=subprocess.STDOUT).strip() + expect('com.atproto.space.getSpaceCredential', 200 if read else 403, + token=delegation, body={'space': space}, headers={'dpop': proof}) + +expect('com.atproto.simplespace.updateSpace', 200, body={'space': space, 'readPolicy': public_policy}) +view = expect('com.atproto.simplespace.getSpace', 200, params={'space': space}) +assert view['readPolicy'] == public_policy and view['writePolicy'] == member_policy and 'policy' not in view +expect('com.atproto.simplespace.updateSpace', 200, body={'space': space, 'writePolicy': public_policy}) +view = expect('com.atproto.simplespace.getSpace', 200, params={'space': space}) +assert view['readPolicy'] == public_policy and view['writePolicy'] == public_policy +expect('com.atproto.simplespace.removeMember', 200, body={'space': space, 'did': member_did}) +expect('com.atproto.simplespace.deleteSpace', 200, body={'space': space}) +print('simplespace access: independent policies, member upserts, credentials, writes, and obsolete payloads passed')