From ea916bc0676fe60fae6913788ef43490264e6a56 Mon Sep 17 00:00:00 2001 From: bitbeckers Date: Thu, 6 Nov 2025 20:56:27 -0300 Subject: [PATCH 1/6] feat(sds): add putRecord endpoint with shared access support --- .../sds/src/api/com/atproto/repo/index.ts | 1 + packages/sds/src/api/com/sds/repo/index.ts | 6 +- .../sds/src/api/com/sds/repo/putRecord.ts | 205 ++++++++++++++++++ 3 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 packages/sds/src/api/com/sds/repo/putRecord.ts diff --git a/packages/sds/src/api/com/atproto/repo/index.ts b/packages/sds/src/api/com/atproto/repo/index.ts index 3bbdfca03bd..fa834faebeb 100644 --- a/packages/sds/src/api/com/atproto/repo/index.ts +++ b/packages/sds/src/api/com/atproto/repo/index.ts @@ -13,6 +13,7 @@ import putRecord from './putRecord' import uploadBlob from './uploadBlob' export default function (server: Server, ctx: AppContext) { + // TODO: remove these endpoints that are overwritten by SDS-specific versions // Skip methods that use findAccount if SDS - they will be registered by SDS-specific versions if (!(ctx instanceof SdsAppContext)) { applyWrites(server, ctx) diff --git a/packages/sds/src/api/com/sds/repo/index.ts b/packages/sds/src/api/com/sds/repo/index.ts index b78a6334c8d..4770a5cc5fc 100644 --- a/packages/sds/src/api/com/sds/repo/index.ts +++ b/packages/sds/src/api/com/sds/repo/index.ts @@ -2,22 +2,22 @@ import { Server } from '../../../../lexicon' import { SdsAppContext } from '../../../../sds-context' import applyWrites from '../../atproto/repo/applyWrites' import deleteRecord from '../../atproto/repo/deleteRecord' -import putRecord from '../../atproto/repo/putRecord' import createRecord from './createRecord' import getPermissions from './getPermissions' import grantAccess from './grantAccess' import listCollaborators from './listCollaborators' +import putRecord from './putRecord' import revokeAccess from './revokeAccess' export default function (server: Server, ctx: SdsAppContext) { - // SDS-specific override for createRecord (supports shared access) + // SDS-specific overrides (support shared access) createRecord(server, ctx) + putRecord(server, ctx) // Temporary: Register standard implementations for methods that use findAccount // These will be replaced with SDS-specific versions that support shared access applyWrites(server, ctx) deleteRecord(server, ctx) - putRecord(server, ctx) grantAccess(server, ctx) revokeAccess(server, ctx) diff --git a/packages/sds/src/api/com/sds/repo/putRecord.ts b/packages/sds/src/api/com/sds/repo/putRecord.ts new file mode 100644 index 00000000000..86d5a81057d --- /dev/null +++ b/packages/sds/src/api/com/sds/repo/putRecord.ts @@ -0,0 +1,205 @@ +import { CID } from 'multiformats/cid' +import { BlobRef } from '@atproto/lexicon' +import { AtUri } from '@atproto/syntax' +import { InvalidRequestError } from '@atproto/xrpc-server' +import { ActorStoreTransactor } from '../../../../actor-store/actor-store-transactor' +import { Server } from '../../../../lexicon' +import { ids } from '../../../../lexicon/lexicons' +import { Record as ProfileRecord } from '../../../../lexicon/types/app/bsky/actor/profile' +import { dbLogger } from '../../../../logger' +import { + BadCommitSwapError, + BadRecordSwapError, + InvalidRecordError, + PreparedCreate, + PreparedUpdate, + prepareCreate, + prepareUpdate, +} from '../../../../repo' +import { SdsAppContext } from '../../../../sds-context' + +export default function (server: Server, ctx: SdsAppContext) { + server.com.atproto.repo.putRecord({ + auth: ctx.authVerifier.authorization({ + // @NOTE the "checkTakedown" and "checkDeactivated" checks are typically + // performed during auth. However, since this method's "repo" parameter + // can be a handle, we will need to fetch the account again to ensure that + // the handle matches the DID from the request's credentials. In order to + // avoid fetching the account twice (during auth, and then again in the + // controller), the checks are disabled here: + + // checkTakedown: true, + // checkDeactivated: true, + authorize: () => { + // Performed in the handler as it requires the request body + }, + }), + rateLimit: [ + { + name: 'repo-write-hour', + calcKey: ({ auth }) => auth.credentials.did, + calcPoints: () => 2, + }, + { + name: 'repo-write-day', + calcKey: ({ auth }) => auth.credentials.did, + calcPoints: () => 2, + }, + ], + handler: async ({ auth, input }) => { + const { + repo, + collection, + rkey, + record, + validate, + swapCommit, + swapRecord, + } = input.body + + const userDid = auth.credentials.did + + // SDS Enhancement: Use enhanced account finder that supports shared access + const { account, accessType } = + await ctx.authVerifier.findAccountWithSharedAccess( + repo, + userDid, + 'write', + { + checkDeactivated: true, + checkTakedown: true, + }, + ) + + const repoDid = account.did + + // OAuth permission checks (same as original PDS) + if (auth.credentials.type === 'oauth' && auth.credentials.permissions) { + auth.credentials.permissions.assertRepo({ + action: 'update', + collection, + }) + } + + // Log shared repository access for audit purposes + if (accessType === 'shared') { + console.log( + `Shared repository access: User ${userDid} creating record in repository ${repoDid}`, + ) + } + + const swapCommitCid = swapCommit ? CID.parse(swapCommit) : undefined + + const uri = AtUri.make(repoDid, collection, rkey) + const swapRecordCid = + typeof swapRecord === 'string' ? CID.parse(swapRecord) : swapRecord + + const { commit, write } = await ctx.actorStore.transact( + repoDid, + async (actorTxn) => { + const current = await actorTxn.record.getRecord(uri, null, true) + const isUpdate = current !== null + + // @TODO temporaray hack for legacy blob refs in profiles - remove after migrating legacy blobs + if (isUpdate && collection === ids.AppBskyActorProfile) { + await updateProfileLegacyBlobRef(actorTxn, record) + } + const writeInfo = { + did: repoDid, + collection, + rkey, + record, + swapCid: swapRecordCid, + validate, + } + + let write: PreparedCreate | PreparedUpdate + try { + write = isUpdate + ? await prepareUpdate(writeInfo) + : await prepareCreate(writeInfo) + } catch (err) { + if (err instanceof InvalidRecordError) { + throw new InvalidRequestError(err.message) + } + throw err + } + + // no-op + if (current && current.cid === write.cid.toString()) { + return { + commit: null, + write, + } + } + + const commit = await actorTxn.repo + .processWrites([write], swapCommitCid) + .catch((err) => { + if ( + err instanceof BadCommitSwapError || + err instanceof BadRecordSwapError + ) { + throw new InvalidRequestError(err.message, 'InvalidSwap') + } else { + throw err + } + }) + + await ctx.sequencer.sequenceCommit(repoDid, commit) + + return { commit, write } + }, + ) + + if (commit !== null) { + await ctx.accountManager + .updateRepoRoot(repoDid, commit.cid, commit.rev) + .catch((err) => { + dbLogger.error( + { err, repoDid, cid: commit.cid, rev: commit.rev }, + 'failed to update account root', + ) + }) + } + + return { + encoding: 'application/json', + body: { + uri: write.uri.toString(), + cid: write.cid.toString(), + commit: commit + ? { + cid: commit.cid.toString(), + rev: commit.rev, + } + : undefined, + validationStatus: write.validationStatus, + }, + } + }, + }) +} + +// WARNING: mutates object +const updateProfileLegacyBlobRef = async ( + actorStore: ActorStoreTransactor, + record: Partial, +) => { + if (record.avatar && !record.avatar.original['$type']) { + const blob = await actorStore.repo.blob.getBlobMetadata(record.avatar.ref) + record.avatar = new BlobRef( + record.avatar.ref, + record.avatar.mimeType, + blob.size, + ) + } + if (record.banner && !record.banner.original['$type']) { + const blob = await actorStore.repo.blob.getBlobMetadata(record.banner.ref) + record.banner = new BlobRef( + record.banner.ref, + record.banner.mimeType, + blob.size, + ) + } +} From 5b61fe5115d69f8a9b1d3e3c444264888c9af8a0 Mon Sep 17 00:00:00 2001 From: bitbeckers Date: Thu, 6 Nov 2025 20:59:16 -0300 Subject: [PATCH 2/6] feat(sds): add applyWrites endpoint with shared access support --- .../sds/src/api/com/sds/repo/applyWrites.ts | 221 ++++++++++++++++++ packages/sds/src/api/com/sds/repo/index.ts | 4 +- 2 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 packages/sds/src/api/com/sds/repo/applyWrites.ts diff --git a/packages/sds/src/api/com/sds/repo/applyWrites.ts b/packages/sds/src/api/com/sds/repo/applyWrites.ts new file mode 100644 index 00000000000..64a8402d9b2 --- /dev/null +++ b/packages/sds/src/api/com/sds/repo/applyWrites.ts @@ -0,0 +1,221 @@ +// SDS Enhanced applyWrites - Supports multi-user repository access +import { CID } from 'multiformats/cid' +import { WriteOpAction } from '@atproto/repo' +import { InvalidRequestError } from '@atproto/xrpc-server' +import { Server } from '../../../../lexicon' +import { + CreateResult, + DeleteResult, + HandlerInput, + UpdateResult, + isCreate, + isDelete, + isUpdate, +} from '../../../../lexicon/types/com/atproto/repo/applyWrites' +import { dbLogger } from '../../../../logger' +import { + BadCommitSwapError, + InvalidRecordError, + PreparedWrite, + prepareCreate, + prepareDelete, + prepareUpdate, +} from '../../../../repo' +import { SdsAppContext } from '../../../../sds-context' + +const ratelimitPoints = ({ input }: { input: HandlerInput }) => { + let points = 0 + for (const op of input.body.writes) { + if (isCreate(op)) { + points += 3 + } else if (isUpdate(op)) { + points += 2 + } else { + points += 1 + } + } + return points +} + +export default function (server: Server, ctx: SdsAppContext) { + server.com.atproto.repo.applyWrites({ + auth: ctx.authVerifier.authorization({ + // @NOTE the "checkTakedown" and "checkDeactivated" checks are typically + // performed during auth. However, since this method's "repo" parameter + // can be a handle, we will need to fetch the account again to ensure that + // the handle matches the DID from the request's credentials. In order to + // avoid fetching the account twice (during auth, and then again in the + // controller), the checks are disabled here: + + // checkTakedown: true, + // checkDeactivated: true, + authorize: () => { + // Performed in the handler as it is based on the request body + }, + }), + + rateLimit: [ + { + name: 'repo-write-hour', + calcKey: ({ auth }) => auth.credentials.did, + calcPoints: ratelimitPoints, + }, + { + name: 'repo-write-day', + calcKey: ({ auth }) => auth.credentials.did, + calcPoints: ratelimitPoints, + }, + ], + + handler: async ({ input, auth }) => { + const { repo, validate, swapCommit, writes } = input.body + + const userDid = auth.credentials.did + + // SDS Enhancement: Use enhanced account finder that supports shared access + const { account, accessType } = + await ctx.authVerifier.findAccountWithSharedAccess( + repo, + userDid, + 'write', + { + checkDeactivated: true, + checkTakedown: true, + }, + ) + + const repoDid = account.did + + if (writes.length > 200) { + throw new InvalidRequestError('Too many writes. Max: 200') + } + + // Verify permission of every unique "action" / "collection" pair + if (auth.credentials.type === 'oauth' && auth.credentials.permissions) { + // @NOTE Unlike "importRepo", we do not require "action" = "*" here. + for (const [action, collections] of [ + ['create', new Set(writes.filter(isCreate).map((w) => w.collection))], + ['update', new Set(writes.filter(isUpdate).map((w) => w.collection))], + ['delete', new Set(writes.filter(isDelete).map((w) => w.collection))], + ] as const) { + for (const collection of collections) { + auth.credentials.permissions.assertRepo({ action, collection }) + } + } + } + + // Log shared repository access for audit purposes + if (accessType === 'shared') { + console.log( + `Shared repository access: User ${userDid} applying writes to repository ${repoDid}`, + ) + } + + // @NOTE should preserve order of ts.writes for final use in response + let preparedWrites: PreparedWrite[] + try { + preparedWrites = await Promise.all( + writes.map(async (write) => { + if (isCreate(write)) { + return prepareCreate({ + did: repoDid, + collection: write.collection, + record: write.value, + rkey: write.rkey, + validate, + }) + } else if (isUpdate(write)) { + return prepareUpdate({ + did: repoDid, + collection: write.collection, + record: write.value, + rkey: write.rkey, + validate, + }) + } else if (isDelete(write)) { + return prepareDelete({ + did: repoDid, + collection: write.collection, + rkey: write.rkey, + }) + } else { + throw new InvalidRequestError( + `Action not supported: ${write['$type']}`, + ) + } + }), + ) + } catch (err) { + if (err instanceof InvalidRecordError) { + throw new InvalidRequestError(err.message) + } + throw err + } + + const swapCommitCid = swapCommit ? CID.parse(swapCommit) : undefined + + const commit = await ctx.actorStore.transact( + repoDid, + async (actorTxn) => { + const commit = await actorTxn.repo + .processWrites(preparedWrites, swapCommitCid) + .catch((err) => { + if (err instanceof BadCommitSwapError) { + throw new InvalidRequestError(err.message, 'InvalidSwap') + } else { + throw err + } + }) + + await ctx.sequencer.sequenceCommit(repoDid, commit) + return commit + }, + ) + + await ctx.accountManager + .updateRepoRoot(repoDid, commit.cid, commit.rev) + .catch((err) => { + dbLogger.error( + { err, did: repoDid, cid: commit.cid, rev: commit.rev }, + 'failed to update account root', + ) + }) + + return { + encoding: 'application/json', + body: { + commit: { + cid: commit.cid.toString(), + rev: commit.rev, + }, + results: preparedWrites.map(writeToOutputResult), + }, + } + }, + }) +} + +const writeToOutputResult = (write: PreparedWrite) => { + switch (write.action) { + case WriteOpAction.Create: + return { + $type: 'com.atproto.repo.applyWrites#createResult', + cid: write.cid.toString(), + uri: write.uri.toString(), + validationStatus: write.validationStatus, + } satisfies CreateResult + case WriteOpAction.Update: + return { + $type: 'com.atproto.repo.applyWrites#updateResult', + cid: write.cid.toString(), + uri: write.uri.toString(), + validationStatus: write.validationStatus, + } satisfies UpdateResult + case WriteOpAction.Delete: + return { + $type: 'com.atproto.repo.applyWrites#deleteResult', + } satisfies DeleteResult + default: + throw new Error(`Unrecognized action: ${write}`) + } +} diff --git a/packages/sds/src/api/com/sds/repo/index.ts b/packages/sds/src/api/com/sds/repo/index.ts index 4770a5cc5fc..e9f448d77c6 100644 --- a/packages/sds/src/api/com/sds/repo/index.ts +++ b/packages/sds/src/api/com/sds/repo/index.ts @@ -1,7 +1,7 @@ import { Server } from '../../../../lexicon' import { SdsAppContext } from '../../../../sds-context' -import applyWrites from '../../atproto/repo/applyWrites' import deleteRecord from '../../atproto/repo/deleteRecord' +import applyWrites from './applyWrites' import createRecord from './createRecord' import getPermissions from './getPermissions' import grantAccess from './grantAccess' @@ -13,10 +13,10 @@ export default function (server: Server, ctx: SdsAppContext) { // SDS-specific overrides (support shared access) createRecord(server, ctx) putRecord(server, ctx) + applyWrites(server, ctx) // Temporary: Register standard implementations for methods that use findAccount // These will be replaced with SDS-specific versions that support shared access - applyWrites(server, ctx) deleteRecord(server, ctx) grantAccess(server, ctx) From 8e41dd4929138b2bcb0b9d9c7199ca26a16883bd Mon Sep 17 00:00:00 2001 From: bitbeckers Date: Thu, 6 Nov 2025 21:00:26 -0300 Subject: [PATCH 3/6] feat(sds): add deleteRecord endpoint with shared access support --- .../sds/src/api/com/sds/repo/deleteRecord.ts | 135 ++++++++++++++++++ packages/sds/src/api/com/sds/repo/index.ts | 5 +- 2 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 packages/sds/src/api/com/sds/repo/deleteRecord.ts diff --git a/packages/sds/src/api/com/sds/repo/deleteRecord.ts b/packages/sds/src/api/com/sds/repo/deleteRecord.ts new file mode 100644 index 00000000000..4132e3bc782 --- /dev/null +++ b/packages/sds/src/api/com/sds/repo/deleteRecord.ts @@ -0,0 +1,135 @@ +// SDS Enhanced deleteRecord - Supports multi-user repository access +import { CID } from 'multiformats/cid' +import { InvalidRequestError } from '@atproto/xrpc-server' +import { Server } from '../../../../lexicon' +import { dbLogger } from '../../../../logger' +import { + BadCommitSwapError, + BadRecordSwapError, + prepareDelete, +} from '../../../../repo' +import { SdsAppContext } from '../../../../sds-context' + +export default function (server: Server, ctx: SdsAppContext) { + server.com.atproto.repo.deleteRecord({ + auth: ctx.authVerifier.authorization({ + // @NOTE the "checkTakedown" and "checkDeactivated" checks are typically + // performed during auth. However, since this method's "repo" parameter + // can be a handle, we will need to fetch the account again to ensure that + // the handle matches the DID from the request's credentials. In order to + // avoid fetching the account twice (during auth, and then again in the + // controller), the checks are disabled here: + + // checkTakedown: true, + // checkDeactivated: true, + authorize: () => { + // Performed in the handler as it requires the request body + }, + }), + rateLimit: [ + { + name: 'repo-write-hour', + calcKey: ({ auth }) => auth.credentials.did, + calcPoints: () => 1, + }, + { + name: 'repo-write-day', + calcKey: ({ auth }) => auth.credentials.did, + calcPoints: () => 1, + }, + ], + handler: async ({ input, auth }) => { + const { repo, collection, rkey, swapCommit, swapRecord } = input.body + + const userDid = auth.credentials.did + + // SDS Enhancement: Use enhanced account finder that supports shared access + const { account, accessType } = + await ctx.authVerifier.findAccountWithSharedAccess( + repo, + userDid, + 'write', + { + checkDeactivated: true, + checkTakedown: true, + }, + ) + + const repoDid = account.did + + // We can't compute permissions based on the request payload ("input") in + // the 'auth' phase, so we do it here. + if (auth.credentials.type === 'oauth' && auth.credentials.permissions) { + auth.credentials.permissions.assertRepo({ + action: 'delete', + collection, + }) + } + + // Log shared repository access for audit purposes + if (accessType === 'shared') { + console.log( + `Shared repository access: User ${userDid} deleting record from repository ${repoDid}`, + ) + } + + const swapCommitCid = swapCommit ? CID.parse(swapCommit) : undefined + const swapRecordCid = swapRecord ? CID.parse(swapRecord) : undefined + + const write = prepareDelete({ + did: repoDid, + collection, + rkey, + swapCid: swapRecordCid, + }) + const commit = await ctx.actorStore.transact( + repoDid, + async (actorTxn) => { + const record = await actorTxn.record.getRecord(write.uri, null, true) + if (!record) { + return null // No-op if record already doesn't exist + } + + const commit = await actorTxn.repo + .processWrites([write], swapCommitCid) + .catch((err) => { + if ( + err instanceof BadCommitSwapError || + err instanceof BadRecordSwapError + ) { + throw new InvalidRequestError(err.message, 'InvalidSwap') + } else { + throw err + } + }) + + await ctx.sequencer.sequenceCommit(repoDid, commit) + return commit + }, + ) + + if (commit !== null) { + await ctx.accountManager + .updateRepoRoot(repoDid, commit.cid, commit.rev) + .catch((err) => { + dbLogger.error( + { err, did: repoDid, cid: commit.cid, rev: commit.rev }, + 'failed to update account root', + ) + }) + } + + return { + encoding: 'application/json', + body: { + commit: commit + ? { + cid: commit.cid.toString(), + rev: commit.rev, + } + : undefined, + }, + } + }, + }) +} diff --git a/packages/sds/src/api/com/sds/repo/index.ts b/packages/sds/src/api/com/sds/repo/index.ts index e9f448d77c6..7b15831428b 100644 --- a/packages/sds/src/api/com/sds/repo/index.ts +++ b/packages/sds/src/api/com/sds/repo/index.ts @@ -1,8 +1,8 @@ import { Server } from '../../../../lexicon' import { SdsAppContext } from '../../../../sds-context' -import deleteRecord from '../../atproto/repo/deleteRecord' import applyWrites from './applyWrites' import createRecord from './createRecord' +import deleteRecord from './deleteRecord' import getPermissions from './getPermissions' import grantAccess from './grantAccess' import listCollaborators from './listCollaborators' @@ -14,9 +14,6 @@ export default function (server: Server, ctx: SdsAppContext) { createRecord(server, ctx) putRecord(server, ctx) applyWrites(server, ctx) - - // Temporary: Register standard implementations for methods that use findAccount - // These will be replaced with SDS-specific versions that support shared access deleteRecord(server, ctx) grantAccess(server, ctx) From d2e0b12e16cbe7a3a1ea0378cd18de15f2d946ec Mon Sep 17 00:00:00 2001 From: bitbeckers Date: Thu, 6 Nov 2025 21:27:39 -0300 Subject: [PATCH 4/6] feat(sds): implement granular permissions for repository access - Add migration 008 for granular permission levels (read, write, admin) - Update grantAccess lexicon to support permission field - Enhance CRUD endpoints with permission-based access control - Update auth verifier and federated token validator - Add permission management UI in sds-demo --- lexicons/com/sds/repo/grantAccess.json | 16 +- packages/dev-env/src/network-with-sds.ts | 4 +- packages/dev-env/src/service-profile.ts | 5 +- .../src/components/collaboration-modal.tsx | 358 +++++++++++------- .../src/components/permission-badge.tsx | 28 +- .../src/components/repository-card.tsx | 2 +- .../src/components/repository-dashboard.tsx | 40 +- .../src/contexts/repository-context.tsx | 6 +- packages/sds-demo/src/lib/sds-lexicons.ts | 118 +++--- .../src/queries/use-collaboration-queries.ts | 12 +- .../db/migrations/007-sds-sharing.ts | 4 +- .../db/migrations/008-granular-permissions.ts | 138 +++++++ .../account-manager/db/migrations/index.ts | 2 + .../sds/src/api/com/sds/repo/applyWrites.ts | 66 +++- .../sds/src/api/com/sds/repo/createRecord.ts | 3 +- .../sds/src/api/com/sds/repo/deleteRecord.ts | 3 +- .../sds/src/api/com/sds/repo/putRecord.ts | 3 +- packages/sds/src/auth-verifier.ts | 15 +- packages/sds/src/lexicon/lexicons.ts | 18 +- .../src/oauth/federated-token-validator.ts | 3 +- packages/sds/src/types.ts | 8 +- .../tests/federated-token-validator.test.ts | 4 +- 22 files changed, 598 insertions(+), 258 deletions(-) create mode 100644 packages/sds/src/account-manager/db/migrations/008-granular-permissions.ts diff --git a/lexicons/com/sds/repo/grantAccess.json b/lexicons/com/sds/repo/grantAccess.json index bcf89af5f9d..400d14efc9d 100644 --- a/lexicons/com/sds/repo/grantAccess.json +++ b/lexicons/com/sds/repo/grantAccess.json @@ -73,16 +73,24 @@ }, "permissions": { "type": "object", - "description": "Repository access permissions", - "required": ["read", "write"], + "description": "Repository access permissions aligned with OAuth's granular action model", + "required": ["read", "create", "update", "delete"], "properties": { "read": { "type": "boolean", "description": "Permission to read repository content." }, - "write": { + "create": { "type": "boolean", - "description": "Permission to write/modify repository content." + "description": "Permission to create new records in the repository." + }, + "update": { + "type": "boolean", + "description": "Permission to update existing records in the repository." + }, + "delete": { + "type": "boolean", + "description": "Permission to delete records from the repository." }, "admin": { "type": "boolean", diff --git a/packages/dev-env/src/network-with-sds.ts b/packages/dev-env/src/network-with-sds.ts index 3a81a22014f..dc521e325a0 100644 --- a/packages/dev-env/src/network-with-sds.ts +++ b/packages/dev-env/src/network-with-sds.ts @@ -128,7 +128,9 @@ export class TestNetworkWithSds extends TestNetworkNoAppView { await lexiconAuthorityProfile.migrateTo(sds) await lexiconAuthorityProfile.createRecords() - console.log(`Lexicon authority ${lexiconAuthorityProfile.did} migrated to both PDS and SDS servers`) + console.log( + `Lexicon authority ${lexiconAuthorityProfile.did} migrated to both PDS and SDS servers`, + ) console.log(`PDS URL: ${pds.url}, SDS URL: ${sds.url}`) await ozone.addAdminDid(ozoneServiceProfile.did) diff --git a/packages/dev-env/src/service-profile.ts b/packages/dev-env/src/service-profile.ts index 41ecb8f2da6..cc530c478a1 100644 --- a/packages/dev-env/src/service-profile.ts +++ b/packages/dev-env/src/service-profile.ts @@ -24,7 +24,10 @@ export class ServiceProfile { return this.client.assertDid } - async migrateTo(newPds: TestPds | any, options: ServiceMigrationOptions = {}) { + async migrateTo( + newPds: TestPds | any, + options: ServiceMigrationOptions = {}, + ) { const newClient = newPds.getClient() const newPdsDesc = await newClient.com.atproto.server.describeServer() diff --git a/packages/sds-demo/src/components/collaboration-modal.tsx b/packages/sds-demo/src/components/collaboration-modal.tsx index f7fa05fe960..1df33bf0f10 100644 --- a/packages/sds-demo/src/components/collaboration-modal.tsx +++ b/packages/sds-demo/src/components/collaboration-modal.tsx @@ -1,19 +1,19 @@ // Repository Collaboration Modal - Manage repository access and collaborators import { useState } from 'react' -import { Button } from './button.tsx' -import { Spinner } from './spinner.tsx' import { + useCanManageRepository, useGrantAccessMutation, - useRevokeAccessMutation, useListCollaboratorsQuery, - useCanManageRepository, + useRevokeAccessMutation, } from '../queries/use-collaboration-queries.ts' import { - validateDid, + type RepositoryPermissions, formatCollaboratorName, getPermissionLevel, - type RepositoryPermissions, + validateDid, } from '../services/collaboration-service.ts' +import { Button } from './button.tsx' +import { Spinner } from './spinner.tsx' interface CollaborationModalProps { isOpen: boolean @@ -28,36 +28,44 @@ export function CollaborationModal({ repositoryDid, repositoryHandle, }: CollaborationModalProps) { - const [activeTab, setActiveTab] = useState<'collaborators' | 'add'>('collaborators') + const [activeTab, setActiveTab] = useState<'collaborators' | 'add'>( + 'collaborators', + ) const [userDid, setUserDid] = useState('') const [permissions, setPermissions] = useState({ read: true, write: false, }) - const [selectedRole, setSelectedRole] = useState<'viewer' | 'contributor' | 'admin'>('viewer') + const [selectedRole, setSelectedRole] = useState< + 'viewer' | 'contributor' | 'admin' + >('viewer') // Role definitions for collaborators (owner role is handled separately) const roles = { viewer: { name: 'Viewer', description: 'Can view repository content', - permissions: { read: true, write: false, admin: false } + permissions: { read: true, write: false, admin: false }, }, contributor: { name: 'Contributor', description: 'Can view and modify repository content', - permissions: { read: true, write: true, admin: false } + permissions: { read: true, write: true, admin: false }, }, admin: { name: 'Admin', description: 'Full access including user management', - permissions: { read: true, write: true, admin: true } - } + permissions: { read: true, write: true, admin: true }, + }, } // Query hooks const collaboratorsQuery = useListCollaboratorsQuery(repositoryDid, isOpen) - const { canManage, isDirectOwner, isLoading: canManageLoading } = useCanManageRepository(repositoryDid) + const { + canManage, + isDirectOwner, + isLoading: canManageLoading, + } = useCanManageRepository(repositoryDid) // Mutation hooks const grantAccessMutation = useGrantAccessMutation() @@ -100,7 +108,11 @@ export function CollaborationModal({ } const handleRevokeAccess = async (collaboratorDid: string) => { - if (!window.confirm(`Are you sure you want to revoke access for ${collaboratorDid}?`)) { + if ( + !window.confirm( + `Are you sure you want to revoke access for ${collaboratorDid}?`, + ) + ) { return } @@ -111,19 +123,24 @@ export function CollaborationModal({ }) } catch (error) { console.error('Failed to revoke access:', error) - alert(`Failed to revoke access: ${error instanceof Error ? error.message : 'Unknown error'}`) + alert( + `Failed to revoke access: ${error instanceof Error ? error.message : 'Unknown error'}`, + ) } } return (
-
+
{/* Header */}
-

Repository Collaboration

+

+ Repository Collaboration +

- Manage access to {repositoryHandle} + Manage access to{' '} + {repositoryHandle}

@@ -147,7 +174,8 @@ export function CollaborationModal({ : 'text-gray-500 hover:text-gray-700' }`} > - Collaborators ({collaboratorsQuery.data?.collaborators?.length || 0}) + Collaborators ({collaboratorsQuery.data?.collaborators?.length || 0} + ) + )}
- {canManage && ( - - )} -
- ))} + ), + )}
)} @@ -272,92 +334,114 @@ export function CollaborationModal({ )} - {activeTab === 'add' && ( - canManage ? ( -
-
- - setUserDid(e.target.value)} - placeholder="did:plc:example123..." - className="mt-1 w-full rounded-lg border border-gray-300 p-3 focus:border-blue-500 focus:outline-none" - /> -

- Enter the DID of the user you want to grant access to -

-
+ {activeTab === 'add' && + (canManage ? ( +
+
+ + setUserDid(e.target.value)} + placeholder="did:plc:example123..." + className="mt-1 w-full rounded-lg border border-gray-300 p-3 focus:border-blue-500 focus:outline-none" + /> +

+ Enter the DID of the user you want to grant access to +

+
-
- -
- {Object.entries(roles).map(([roleKey, role]) => ( -
) : (
- +
-

Owner Access Required

+

+ Owner Access Required +

Only repository owners can add new collaborators.

- ) - )} + ))} {/* Footer */}
@@ -366,4 +450,4 @@ export function CollaborationModal({
) -} \ No newline at end of file +} diff --git a/packages/sds-demo/src/components/permission-badge.tsx b/packages/sds-demo/src/components/permission-badge.tsx index 31f1c64a855..128d9eac8bc 100644 --- a/packages/sds-demo/src/components/permission-badge.tsx +++ b/packages/sds-demo/src/components/permission-badge.tsx @@ -1,5 +1,8 @@ // Permission Badge Component - Visual indicator for permission levels -import { type RepositoryPermissions, getPermissionLevel } from '../services/collaboration-service.ts' +import { + type RepositoryPermissions, + getPermissionLevel, +} from '../services/collaboration-service.ts' interface PermissionBadgeProps { permissions: RepositoryPermissions @@ -7,7 +10,11 @@ interface PermissionBadgeProps { className?: string } -export function PermissionBadge({ permissions, size = 'medium', className = '' }: PermissionBadgeProps) { +export function PermissionBadge({ + permissions, + size = 'medium', + className = '', +}: PermissionBadgeProps) { const level = getPermissionLevel(permissions) // Determine styling based on permission level @@ -19,8 +26,8 @@ export function PermissionBadge({ permissions, size = 'medium', className = '' } } const colorStyles = { - 'Owner': 'bg-red-100 text-red-800', - 'Admin': 'bg-purple-100 text-purple-800', + Owner: 'bg-red-100 text-red-800', + Admin: 'bg-purple-100 text-purple-800', 'Read & Write': 'bg-green-100 text-green-800', 'Read Only': 'bg-blue-100 text-blue-800', 'No Access': 'bg-gray-100 text-gray-800', @@ -29,11 +36,7 @@ export function PermissionBadge({ permissions, size = 'medium', className = '' } return `${baseStyles[size]} ${colorStyles[level as keyof typeof colorStyles]} font-medium rounded-full` } - return ( - - {level} - - ) + return {level} } interface DetailedPermissionBadgesProps { @@ -41,7 +44,10 @@ interface DetailedPermissionBadgesProps { className?: string } -export function DetailedPermissionBadges({ permissions, className = '' }: DetailedPermissionBadgesProps) { +export function DetailedPermissionBadges({ + permissions, + className = '', +}: DetailedPermissionBadgesProps) { return (
) -} \ No newline at end of file +} diff --git a/packages/sds-demo/src/components/repository-card.tsx b/packages/sds-demo/src/components/repository-card.tsx index 05fa4e9969d..9b19a742864 100644 --- a/packages/sds-demo/src/components/repository-card.tsx +++ b/packages/sds-demo/src/components/repository-card.tsx @@ -1,7 +1,7 @@ // Repository Card Component - Display repository info with collaboration features import { Repository } from '../contexts/repository-context.tsx' -import { Button } from './button.tsx' import { useListCollaboratorsQuery } from '../queries/use-collaboration-queries.ts' +import { Button } from './button.tsx' interface RepositoryCardProps { repository: Repository diff --git a/packages/sds-demo/src/components/repository-dashboard.tsx b/packages/sds-demo/src/components/repository-dashboard.tsx index 17dbf2e9352..e53bb558923 100644 --- a/packages/sds-demo/src/components/repository-dashboard.tsx +++ b/packages/sds-demo/src/components/repository-dashboard.tsx @@ -3,23 +3,23 @@ import { useEffect, useState } from 'react' import { Agent } from '@atproto/api' import { useAuthContext } from '../auth/auth-provider.tsx' import { SDS_SERVER_URL } from '../constants.ts' -import { addSdsLexicons } from '../lib/sds-lexicons.ts' import { Repository, useRepositoryContext, } from '../contexts/repository-context.tsx' +import { addSdsLexicons } from '../lib/sds-lexicons.ts' +import { useListCollaboratorsQuery } from '../queries/use-collaboration-queries.ts' import { useCreateRecordMutation, useListOrganizationsQuery, } from '../queries/use-sds-queries.ts' -import { useListCollaboratorsQuery } from '../queries/use-collaboration-queries.ts' import { retryApiCall } from '../utils/api-retry.ts' import { Button } from './button.tsx' -import { Spinner } from './spinner.tsx' +import { CollaborationDebug } from './collaboration-debug.tsx' import { CollaborationModal } from './collaboration-modal.tsx' import { PermissionBadge } from './permission-badge.tsx' import { RepositoryCard } from './repository-card.tsx' -import { CollaborationDebug } from './collaboration-debug.tsx' +import { Spinner } from './spinner.tsx' export function RepositoryDashboard() { const auth = useAuthContext() @@ -46,7 +46,10 @@ export function RepositoryDashboard() { const organizationsQuery = useListOrganizationsQuery() // Helper functions for collaboration modal - const openCollaborationModal = (repositoryDid: string, repositoryHandle: string) => { + const openCollaborationModal = ( + repositoryDid: string, + repositoryHandle: string, + ) => { setCollaborationModal({ isOpen: true, repositoryDid, @@ -72,7 +75,7 @@ export function RepositoryDashboard() { accessType: org.accessType, permissions: { read: org.permissions.read, - write: org.permissions.write + write: org.permissions.write, }, collaboratorCount: 1, }), @@ -107,17 +110,22 @@ export function RepositoryDashboard() { creatorDid: session.did, } - console.log('[SDS Demo] Making organization creation request via agent...') + console.log( + '[SDS Demo] Making organization creation request via agent...', + ) console.log('[SDS Demo] Request payload:', requestPayload) // Use the SDS agent to make the call with proper lexicon routing const agentResponse = await auth.agent.call( 'com.sds.organization.create', undefined, - requestPayload + requestPayload, ) - console.log('[SDS Demo] Organization created successfully:', agentResponse.data) + console.log( + '[SDS Demo] Organization created successfully:', + agentResponse.data, + ) return agentResponse }) @@ -201,7 +209,9 @@ You are the owner and can now invite collaborators to share this repository.`) {/* Header */}
-

Your Shared Repositories

+

+ Your Shared Repositories +

{repositories.length} repositories @@ -223,7 +233,8 @@ You are the owner and can now invite collaborators to share this repository.`) Create Shared Repository

- Create a new repository on the SDS that you own and can share with collaborators. + Create a new repository on the SDS that you own and can share with + collaborators.

@@ -291,7 +302,8 @@ You are the owner and can now invite collaborators to share this repository.`) No shared repositories yet

- Create your first shared repository to start collaborating with others. + Create your first shared repository to start collaborating with + others.

diff --git a/packages/sds-demo/src/contexts/repository-context.tsx b/packages/sds-demo/src/contexts/repository-context.tsx index d31fc5bbca3..0454e6c3a99 100644 --- a/packages/sds-demo/src/contexts/repository-context.tsx +++ b/packages/sds-demo/src/contexts/repository-context.tsx @@ -65,10 +65,8 @@ export function RepositoryProvider({ children }: RepositoryProviderProps) { const updateCollaborators = (did: string, collaboratorCount: number) => { setRepositories((prev) => prev.map((repo) => - repo.did === did - ? { ...repo, collaboratorCount } - : repo - ) + repo.did === did ? { ...repo, collaboratorCount } : repo, + ), ) } diff --git a/packages/sds-demo/src/lib/sds-lexicons.ts b/packages/sds-demo/src/lib/sds-lexicons.ts index ce67811c70c..3b1d610baf7 100644 --- a/packages/sds-demo/src/lib/sds-lexicons.ts +++ b/packages/sds-demo/src/lib/sds-lexicons.ts @@ -8,7 +8,8 @@ export const SDS_LEXICONS: LexiconDoc[] = [ defs: { main: { type: 'procedure', - description: 'Create a new organization with its own repository that can be shared with collaborators.', + description: + 'Create a new organization with its own repository that can be shared with collaborators.', input: { encoding: 'application/json', schema: { @@ -18,31 +19,38 @@ export const SDS_LEXICONS: LexiconDoc[] = [ name: { type: 'string', maxLength: 100, - description: 'The name of the organization.' + description: 'The name of the organization.', }, description: { type: 'string', maxLength: 500, - description: 'Optional description of the organization.' + description: 'Optional description of the organization.', }, handle: { type: 'string', format: 'handle', - description: 'Optional custom handle for the organization.' + description: 'Optional custom handle for the organization.', }, creatorDid: { type: 'string', format: 'did', - description: 'DID of the user creating the organization.' - } - } - } + description: 'DID of the user creating the organization.', + }, + }, + }, }, output: { encoding: 'application/json', schema: { type: 'object', - required: ['did', 'handle', 'name', 'createdAt', 'permissions', 'accessType'], + required: [ + 'did', + 'handle', + 'name', + 'createdAt', + 'permissions', + 'accessType', + ], properties: { did: { type: 'string', format: 'did' }, handle: { type: 'string', format: 'handle' }, @@ -51,14 +59,14 @@ export const SDS_LEXICONS: LexiconDoc[] = [ createdAt: { type: 'string', format: 'datetime' }, permissions: { type: 'ref', - ref: 'com.sds.repo.grantAccess#permissions' + ref: 'com.sds.repo.grantAccess#permissions', }, - accessType: { type: 'string', knownValues: ['owner'] } - } - } - } - } - } + accessType: { type: 'string', knownValues: ['owner'] }, + }, + }, + }, + }, + }, }, { lexicon: 1, @@ -66,16 +74,17 @@ export const SDS_LEXICONS: LexiconDoc[] = [ defs: { main: { type: 'query', - description: 'List organizations that the authenticated user has access to.', + description: + 'List organizations that the authenticated user has access to.', parameters: { type: 'params', properties: { userDid: { type: 'string', format: 'did', - description: 'DID of the user to list organizations for.' - } - } + description: 'DID of the user to list organizations for.', + }, + }, }, output: { encoding: 'application/json', @@ -87,12 +96,12 @@ export const SDS_LEXICONS: LexiconDoc[] = [ type: 'array', items: { type: 'ref', - ref: '#organization' - } - } - } - } - } + ref: '#organization', + }, + }, + }, + }, + }, }, organization: { type: 'object', @@ -102,38 +111,38 @@ export const SDS_LEXICONS: LexiconDoc[] = [ did: { type: 'string', format: 'did', - description: 'The DID of the organization repository.' + description: 'The DID of the organization repository.', }, handle: { type: 'string', format: 'handle', - description: 'The handle of the organization.' + description: 'The handle of the organization.', }, name: { type: 'string', - description: 'The name of the organization.' + description: 'The name of the organization.', }, description: { type: 'string', - description: 'The description of the organization.' + description: 'The description of the organization.', }, createdAt: { type: 'string', format: 'datetime', - description: 'When the organization was created.' + description: 'When the organization was created.', }, permissions: { type: 'ref', - ref: 'com.sds.repo.grantAccess#permissions' + ref: 'com.sds.repo.grantAccess#permissions', }, accessType: { type: 'string', knownValues: ['owner', 'collaborator'], - description: 'The users access type.' - } - } - } - } + description: 'The users access type.', + }, + }, + }, + }, }, { lexicon: 1, @@ -141,7 +150,8 @@ export const SDS_LEXICONS: LexiconDoc[] = [ defs: { main: { type: 'procedure', - description: 'Grant access permissions to a user for a shared repository.', + description: + 'Grant access permissions to a user for a shared repository.', input: { encoding: 'application/json', schema: { @@ -152,11 +162,11 @@ export const SDS_LEXICONS: LexiconDoc[] = [ userDid: { type: 'string', format: 'did' }, permissions: { type: 'ref', - ref: '#permissions' - } - } - } - } + ref: '#permissions', + }, + }, + }, + }, }, permissions: { type: 'object', @@ -165,24 +175,26 @@ export const SDS_LEXICONS: LexiconDoc[] = [ properties: { read: { type: 'boolean', - description: 'Permission to read repository content.' + description: 'Permission to read repository content.', }, write: { type: 'boolean', - description: 'Permission to write/modify repository content.' + description: 'Permission to write/modify repository content.', }, admin: { type: 'boolean', - description: 'Administrative permissions (manage collaborators, etc.).' + description: + 'Administrative permissions (manage collaborators, etc.).', }, owner: { type: 'boolean', - description: 'Ownership permissions (transfer repository, full control).' - } - } - } - } - } + description: + 'Ownership permissions (transfer repository, full control).', + }, + }, + }, + }, + }, ] // Helper function to add SDS lexicons to an agent @@ -190,4 +202,4 @@ export function addSdsLexicons(agent: any) { for (const lexicon of SDS_LEXICONS) { agent.lex.add(lexicon) } -} \ No newline at end of file +} diff --git a/packages/sds-demo/src/queries/use-collaboration-queries.ts b/packages/sds-demo/src/queries/use-collaboration-queries.ts index 8f1ddf8fc7f..e943be31188 100644 --- a/packages/sds-demo/src/queries/use-collaboration-queries.ts +++ b/packages/sds-demo/src/queries/use-collaboration-queries.ts @@ -4,15 +4,15 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useAuthContext } from '../auth/auth-provider.tsx' import { - grantRepositoryAccess, - revokeRepositoryAccess, - listRepositoryCollaborators, - getRepositoryPermissions, + type Collaborator, + type GetPermissionsResponse, type GrantAccessRequest, type GrantAccessResponse, - type Collaborator, type ListCollaboratorsResponse, - type GetPermissionsResponse, + getRepositoryPermissions, + grantRepositoryAccess, + listRepositoryCollaborators, + revokeRepositoryAccess, } from '../services/collaboration-service.ts' // Query key factory for collaboration-related queries diff --git a/packages/sds/src/account-manager/db/migrations/007-sds-sharing.ts b/packages/sds/src/account-manager/db/migrations/007-sds-sharing.ts index bba0d56ae66..880e5f0aa4d 100644 --- a/packages/sds/src/account-manager/db/migrations/007-sds-sharing.ts +++ b/packages/sds/src/account-manager/db/migrations/007-sds-sharing.ts @@ -7,7 +7,9 @@ export async function up(db: Kysely): Promise { .addColumn('repoDid', 'varchar', (col) => col.notNull()) .addColumn('userDid', 'varchar', (col) => col.notNull()) .addColumn('permissions', 'varchar', (col) => - col.notNull().defaultTo('{"read":true,"write":true}'), + col + .notNull() + .defaultTo('{"read":true,"create":true,"update":true,"delete":true}'), ) .addColumn('grantedBy', 'varchar', (col) => col.notNull()) .addColumn('grantedAt', 'varchar', (col) => diff --git a/packages/sds/src/account-manager/db/migrations/008-granular-permissions.ts b/packages/sds/src/account-manager/db/migrations/008-granular-permissions.ts new file mode 100644 index 00000000000..a3f89fd4379 --- /dev/null +++ b/packages/sds/src/account-manager/db/migrations/008-granular-permissions.ts @@ -0,0 +1,138 @@ +import { Kysely } from 'kysely' + +/** + * Migration to align SDS RBAC permissions with OAuth's granular action model + * Transforms generic 'write' permission into specific 'create', 'update', 'delete' permissions + */ +export async function up( + db: Kysely<{ + shared_repository_permissions: { + repoDid: string + userDid: string + permissions: string + grantedBy: string + grantedAt: string + revokedAt: string | null + } + }>, +): Promise { + // Get all existing permission records + const records = await db + .selectFrom('shared_repository_permissions') + .select(['repoDid', 'userDid', 'permissions']) + .execute() + + console.log( + `Migrating ${records.length} permission records to granular model...`, + ) + + // Transform each record + for (const record of records) { + try { + const oldPermissions = JSON.parse(record.permissions as string) + const newPermissions: any = { + read: oldPermissions.read ?? false, + create: false, + update: false, + delete: false, + } + + // Transform generic 'write' into granular permissions + if (oldPermissions.write === true) { + newPermissions.create = true + newPermissions.update = true + newPermissions.delete = true + } + + // Preserve optional fields + if (oldPermissions.admin !== undefined) { + newPermissions.admin = oldPermissions.admin + } + if (oldPermissions.owner !== undefined) { + newPermissions.owner = oldPermissions.owner + } + + // Update the record + await db + .updateTable('shared_repository_permissions') + .set({ permissions: JSON.stringify(newPermissions) }) + .where('repoDid', '=', record.repoDid) + .where('userDid', '=', record.userDid) + .execute() + } catch (error) { + console.error( + `Failed to migrate permissions for ${record.repoDid}/${record.userDid}:`, + error, + ) + throw error + } + } + + console.log('✅ Permission migration completed successfully') +} + +export async function down( + db: Kysely<{ + shared_repository_permissions: { + repoDid: string + userDid: string + permissions: string + grantedBy: string + grantedAt: string + revokedAt: string | null + } + }>, +): Promise { + // Get all existing permission records + const records = await db + .selectFrom('shared_repository_permissions') + .select(['repoDid', 'userDid', 'permissions']) + .execute() + + console.log(`Rolling back ${records.length} permission records...`) + + // Transform each record back to old format + for (const record of records) { + try { + const newPermissions = JSON.parse(record.permissions as string) + const oldPermissions: any = { + read: newPermissions.read ?? false, + write: false, + } + + // Transform granular permissions back to generic 'write' + // User has 'write' if they have ANY of create/update/delete + if ( + newPermissions.create === true || + newPermissions.update === true || + newPermissions.delete === true + ) { + oldPermissions.write = true + } + + // Preserve optional fields + if (newPermissions.admin !== undefined) { + oldPermissions.admin = newPermissions.admin + } + if (newPermissions.owner !== undefined) { + oldPermissions.owner = newPermissions.owner + } + + // Update the record + await db + .updateTable('shared_repository_permissions') + .set({ permissions: JSON.stringify(oldPermissions) }) + .where('repoDid', '=', record.repoDid) + .where('userDid', '=', record.userDid) + .execute() + } catch (error) { + console.error( + `Failed to rollback permissions for ${record.repoDid}/${record.userDid}:`, + error, + ) + throw error + } + } + + console.log('✅ Permission rollback completed successfully') +} diff --git a/packages/sds/src/account-manager/db/migrations/index.ts b/packages/sds/src/account-manager/db/migrations/index.ts index 5fe577e8d63..3c15ccb8a6e 100644 --- a/packages/sds/src/account-manager/db/migrations/index.ts +++ b/packages/sds/src/account-manager/db/migrations/index.ts @@ -5,6 +5,7 @@ import * as mig004 from './004-oauth' import * as mig005 from './005-oauth-account-management' import * as mig006 from './006-oauth-permission-sets' import * as mig007 from './007-sds-sharing' +import * as mig008 from './008-granular-permissions' export default { '001': mig001, @@ -14,4 +15,5 @@ export default { '005': mig005, '006': mig006, '007': mig007, + '008': mig008, } diff --git a/packages/sds/src/api/com/sds/repo/applyWrites.ts b/packages/sds/src/api/com/sds/repo/applyWrites.ts index 64a8402d9b2..f87e4d73826 100644 --- a/packages/sds/src/api/com/sds/repo/applyWrites.ts +++ b/packages/sds/src/api/com/sds/repo/applyWrites.ts @@ -72,20 +72,64 @@ export default function (server: Server, ctx: SdsAppContext) { const userDid = auth.credentials.did - // SDS Enhancement: Use enhanced account finder that supports shared access - const { account, accessType } = - await ctx.authVerifier.findAccountWithSharedAccess( - repo, - userDid, - 'write', - { - checkDeactivated: true, - checkTakedown: true, - }, - ) + // SDS Enhancement: Determine required permissions based on write operations + const hasCreates = writes.some(isCreate) + const hasUpdates = writes.some(isUpdate) + const hasDeletes = writes.some(isDelete) + // Get account and check initial access + const account = await ctx.authVerifier.findAccount(repo, { + checkDeactivated: true, + checkTakedown: true, + }) const repoDid = account.did + // If not the owner, verify user has all necessary granular permissions + let accessType: 'owner' | 'shared' = 'owner' + if (repoDid !== userDid) { + accessType = 'shared' + + // Check each required permission type + if (hasCreates) { + const hasCreateAccess = await ctx.authVerifier.checkRepositoryAccess( + repoDid, + userDid, + 'create', + ) + if (!hasCreateAccess) { + throw new InvalidRequestError( + `Access denied: User ${userDid} does not have 'create' permission for repository ${repoDid}`, + ) + } + } + + if (hasUpdates) { + const hasUpdateAccess = await ctx.authVerifier.checkRepositoryAccess( + repoDid, + userDid, + 'update', + ) + if (!hasUpdateAccess) { + throw new InvalidRequestError( + `Access denied: User ${userDid} does not have 'update' permission for repository ${repoDid}`, + ) + } + } + + if (hasDeletes) { + const hasDeleteAccess = await ctx.authVerifier.checkRepositoryAccess( + repoDid, + userDid, + 'delete', + ) + if (!hasDeleteAccess) { + throw new InvalidRequestError( + `Access denied: User ${userDid} does not have 'delete' permission for repository ${repoDid}`, + ) + } + } + } + if (writes.length > 200) { throw new InvalidRequestError('Too many writes. Max: 200') } diff --git a/packages/sds/src/api/com/sds/repo/createRecord.ts b/packages/sds/src/api/com/sds/repo/createRecord.ts index 600d82b8327..6c087299246 100644 --- a/packages/sds/src/api/com/sds/repo/createRecord.ts +++ b/packages/sds/src/api/com/sds/repo/createRecord.ts @@ -45,11 +45,12 @@ export default function (server: Server, ctx: SdsAppContext) { const userDid = auth.credentials.did // SDS Enhancement: Use enhanced account finder that supports shared access + // Check for 'create' permission (aligned with OAuth scope model) const { account, accessType } = await ctx.authVerifier.findAccountWithSharedAccess( repo, userDid, - 'write', + 'create', { checkDeactivated: true, checkTakedown: true, diff --git a/packages/sds/src/api/com/sds/repo/deleteRecord.ts b/packages/sds/src/api/com/sds/repo/deleteRecord.ts index 4132e3bc782..8bee8313060 100644 --- a/packages/sds/src/api/com/sds/repo/deleteRecord.ts +++ b/packages/sds/src/api/com/sds/repo/deleteRecord.ts @@ -44,11 +44,12 @@ export default function (server: Server, ctx: SdsAppContext) { const userDid = auth.credentials.did // SDS Enhancement: Use enhanced account finder that supports shared access + // Check for 'delete' permission (aligned with OAuth scope model) const { account, accessType } = await ctx.authVerifier.findAccountWithSharedAccess( repo, userDid, - 'write', + 'delete', { checkDeactivated: true, checkTakedown: true, diff --git a/packages/sds/src/api/com/sds/repo/putRecord.ts b/packages/sds/src/api/com/sds/repo/putRecord.ts index 86d5a81057d..7f1390b2984 100644 --- a/packages/sds/src/api/com/sds/repo/putRecord.ts +++ b/packages/sds/src/api/com/sds/repo/putRecord.ts @@ -60,11 +60,12 @@ export default function (server: Server, ctx: SdsAppContext) { const userDid = auth.credentials.did // SDS Enhancement: Use enhanced account finder that supports shared access + // Check for 'update' permission (aligned with OAuth scope model) const { account, accessType } = await ctx.authVerifier.findAccountWithSharedAccess( repo, userDid, - 'write', + 'update', { checkDeactivated: true, checkTakedown: true, diff --git a/packages/sds/src/auth-verifier.ts b/packages/sds/src/auth-verifier.ts index fcec389edd0..c6bd100e5c1 100644 --- a/packages/sds/src/auth-verifier.ts +++ b/packages/sds/src/auth-verifier.ts @@ -250,7 +250,10 @@ export class AuthVerifier { const type = extractAuthType(ctx.req) console.log('[Auth] Request URL:', ctx.req.url) console.log('[Auth] Detected auth type:', type) - console.log('[Auth] Authorization header present:', !!ctx.req.headers.authorization) + console.log( + '[Auth] Authorization header present:', + !!ctx.req.headers.authorization, + ) if (type === AuthType.BEARER) { console.log('[Auth] Routing to access method (Bearer)') @@ -614,7 +617,10 @@ const parseAuthorizationHeader = ( req: IncomingMessage, ): [type: null] | [type: AuthType, token: string] => { const authorization = req.headers['authorization'] - console.log('[Auth] Parsing authorization header:', authorization?.slice(0, 30) + '...') + console.log( + '[Auth] Parsing authorization header:', + authorization?.slice(0, 30) + '...', + ) if (!authorization) { console.log('[Auth] No authorization header found') @@ -623,7 +629,10 @@ const parseAuthorizationHeader = ( const result = authorization.split(' ') if (result.length !== 2) { - console.log('[Auth] Malformed authorization header - wrong number of parts:', result.length) + console.log( + '[Auth] Malformed authorization header - wrong number of parts:', + result.length, + ) throw new InvalidRequestError( 'Malformed authorization header', 'InvalidToken', diff --git a/packages/sds/src/lexicon/lexicons.ts b/packages/sds/src/lexicon/lexicons.ts index b9fe18be340..4f01c5451cc 100644 --- a/packages/sds/src/lexicon/lexicons.ts +++ b/packages/sds/src/lexicon/lexicons.ts @@ -14112,16 +14112,26 @@ export const schemaDict = { }, permissions: { type: 'object', - description: 'Repository access permissions', - required: ['read', 'write'], + description: + "Repository access permissions aligned with OAuth's granular action model", + required: ['read', 'create', 'update', 'delete'], properties: { read: { type: 'boolean', description: 'Permission to read repository content.', }, - write: { + create: { + type: 'boolean', + description: 'Permission to create new records in the repository.', + }, + update: { + type: 'boolean', + description: + 'Permission to update existing records in the repository.', + }, + delete: { type: 'boolean', - description: 'Permission to write/modify repository content.', + description: 'Permission to delete records from the repository.', }, admin: { type: 'boolean', diff --git a/packages/sds/src/oauth/federated-token-validator.ts b/packages/sds/src/oauth/federated-token-validator.ts index 273cd71ffb6..246d124c14a 100644 --- a/packages/sds/src/oauth/federated-token-validator.ts +++ b/packages/sds/src/oauth/federated-token-validator.ts @@ -1,6 +1,5 @@ +import { JoseKey, Keyset } from '@atproto/oauth-provider' import { AuthRequiredError } from '@atproto/xrpc-server' -import { Keyset } from '@atproto/oauth-provider' -import { JoseKey } from '@atproto/oauth-provider' /** * Result of token validation diff --git a/packages/sds/src/types.ts b/packages/sds/src/types.ts index e81806833f0..fd1f56d5b4c 100644 --- a/packages/sds/src/types.ts +++ b/packages/sds/src/types.ts @@ -1,8 +1,14 @@ // SDS-specific types and interfaces +/** + * Repository permissions aligned with OAuth's granular action model + * This ensures consistency between SDS RBAC and OAuth scope validation + */ export interface RepositoryPermissions { read: boolean - write: boolean + create: boolean + update: boolean + delete: boolean admin?: boolean owner?: boolean } diff --git a/packages/sds/tests/federated-token-validator.test.ts b/packages/sds/tests/federated-token-validator.test.ts index e98d770f77c..565468538c8 100644 --- a/packages/sds/tests/federated-token-validator.test.ts +++ b/packages/sds/tests/federated-token-validator.test.ts @@ -9,9 +9,9 @@ * - Supports both Bearer and DPoP tokens */ -import { FederatedTokenValidator } from '../src/oauth/federated-token-validator' +import { JoseKey, Keyset } from '@atproto/oauth-provider' import { AuthRequiredError } from '@atproto/xrpc-server' -import { Keyset, JoseKey } from '@atproto/oauth-provider' +import { FederatedTokenValidator } from '../src/oauth/federated-token-validator' // Mock fetch for testing global.fetch = jest.fn() From 51b1e5ca1374858ee35ccda7b8df80c538efbe29 Mon Sep 17 00:00:00 2001 From: bitbeckers Date: Thu, 6 Nov 2025 21:53:54 -0300 Subject: [PATCH 5/6] feat(sds/repo): add ownership transfer and enhance permission management --- lexicons/com/sds/repo/grantAccess.json | 4 + lexicons/com/sds/repo/transferOwnership.json | 59 +++++++++ .../src/api/com/sds/organization/create.ts | 15 ++- .../sds/src/api/com/sds/organization/list.ts | 4 +- .../src/api/com/sds/repo/getPermissions.ts | 8 +- .../sds/src/api/com/sds/repo/grantAccess.ts | 57 +++++---- packages/sds/src/api/com/sds/repo/index.ts | 2 + .../sds/src/api/com/sds/repo/revokeAccess.ts | 33 ++--- .../src/api/com/sds/repo/transferOwnership.ts | 100 +++++++++++++++ packages/sds/src/lexicon/index.ts | 13 ++ packages/sds/src/lexicon/lexicons.ts | 66 ++++++++++ .../lexicon/types/com/sds/repo/grantAccess.ts | 12 +- .../types/com/sds/repo/transferOwnership.ts | 50 ++++++++ packages/sds/src/permission-manager/index.ts | 121 +++++++++++++++++- packages/sds/src/sds-auth-verifier.ts | 42 ++++-- packages/sds/src/types.ts | 3 + 16 files changed, 513 insertions(+), 76 deletions(-) create mode 100644 lexicons/com/sds/repo/transferOwnership.json create mode 100644 packages/sds/src/api/com/sds/repo/transferOwnership.ts create mode 100644 packages/sds/src/lexicon/types/com/sds/repo/transferOwnership.ts diff --git a/lexicons/com/sds/repo/grantAccess.json b/lexicons/com/sds/repo/grantAccess.json index 400d14efc9d..91ee0531e19 100644 --- a/lexicons/com/sds/repo/grantAccess.json +++ b/lexicons/com/sds/repo/grantAccess.json @@ -95,6 +95,10 @@ "admin": { "type": "boolean", "description": "Administrative permissions (manage collaborators, etc.)." + }, + "owner": { + "type": "boolean", + "description": "Owner permissions (full control including ownership transfer)." } } }, diff --git a/lexicons/com/sds/repo/transferOwnership.json b/lexicons/com/sds/repo/transferOwnership.json new file mode 100644 index 00000000000..f3877321c50 --- /dev/null +++ b/lexicons/com/sds/repo/transferOwnership.json @@ -0,0 +1,59 @@ +{ + "lexicon": 1, + "id": "com.sds.repo.transferOwnership", + "defs": { + "main": { + "type": "procedure", + "description": "Transfer repository ownership to another user. Only the current owner can perform this operation.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["repo", "newOwnerDid"], + "properties": { + "repo": { + "type": "string", + "format": "at-identifier", + "description": "The handle or DID of the repository." + }, + "newOwnerDid": { + "type": "string", + "format": "did", + "description": "The DID of the new owner." + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["success", "previousOwner", "newOwner", "transferredAt"], + "properties": { + "success": { + "type": "boolean" + }, + "previousOwner": { + "type": "string", + "format": "did" + }, + "newOwner": { + "type": "string", + "format": "did" + }, + "transferredAt": { + "type": "string", + "format": "datetime" + } + } + } + }, + "errors": [ + { + "name": "Unauthorized", + "description": "Only the repository owner can transfer ownership." + } + ] + } + } +} diff --git a/packages/sds/src/api/com/sds/organization/create.ts b/packages/sds/src/api/com/sds/organization/create.ts index 09e9d933a75..081cb50d4de 100644 --- a/packages/sds/src/api/com/sds/organization/create.ts +++ b/packages/sds/src/api/com/sds/organization/create.ts @@ -21,7 +21,7 @@ export default function (server: Server, ctx: SdsAppContext) { calcPoints: () => 10, }, ], - handler: async ({ input, auth }) => { + handler: async ({ input, auth: _auth }) => { const { name, description, handle, creatorDid } = input.body if (!creatorDid) { @@ -85,7 +85,14 @@ export default function (server: Server, ctx: SdsAppContext) { await ctx.permissionManager.grantAccess( orgDid, creatorDid, - { read: true, write: true, admin: true, owner: true }, + { + read: true, + create: true, + update: true, + delete: true, + admin: true, + owner: true, + }, creatorDid, // Self-granted as the creator ) @@ -110,7 +117,9 @@ export default function (server: Server, ctx: SdsAppContext) { // The creating user has full ownership rights through SDS RBAC permissions: { read: true, - write: true, + create: true, + update: true, + delete: true, admin: true, owner: true, }, diff --git a/packages/sds/src/api/com/sds/organization/list.ts b/packages/sds/src/api/com/sds/organization/list.ts index c78586ed21a..962b1584073 100644 --- a/packages/sds/src/api/com/sds/organization/list.ts +++ b/packages/sds/src/api/com/sds/organization/list.ts @@ -40,7 +40,9 @@ export default function (server: Server, ctx: SdsAppContext) { createdAt: account.createdAt || new Date().toISOString(), permissions: { read: permissions.read, - write: permissions.write, + create: permissions.create, + update: permissions.update, + delete: permissions.delete, admin: permissions.admin || false, }, accessType: permissions.admin ? 'owner' : 'collaborator', diff --git a/packages/sds/src/api/com/sds/repo/getPermissions.ts b/packages/sds/src/api/com/sds/repo/getPermissions.ts index 174ed0347ac..92d88488293 100644 --- a/packages/sds/src/api/com/sds/repo/getPermissions.ts +++ b/packages/sds/src/api/com/sds/repo/getPermissions.ts @@ -38,7 +38,9 @@ export default function (server: Server, ctx: SdsAppContext) { body: { permissions: { read: true, - write: true, + create: true, + update: true, + delete: true, admin: true, }, accessType: 'owner', @@ -75,7 +77,9 @@ export default function (server: Server, ctx: SdsAppContext) { body: { permissions: { read: false, - write: false, + create: false, + update: false, + delete: false, admin: false, }, accessType: 'none', diff --git a/packages/sds/src/api/com/sds/repo/grantAccess.ts b/packages/sds/src/api/com/sds/repo/grantAccess.ts index a218628f5cf..b9c183642c0 100644 --- a/packages/sds/src/api/com/sds/repo/grantAccess.ts +++ b/packages/sds/src/api/com/sds/repo/grantAccess.ts @@ -1,7 +1,6 @@ // SDS Grant Access Endpoint - Allows repository owners to grant access to collaborators import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' -import { ids } from '../../../../lexicon/lexicons' import { SdsAppContext } from '../../../../sds-context' import { RepositoryPermissions, SdsPermissionError } from '../../../../types' @@ -32,16 +31,19 @@ export default function (server: Server, ctx: SdsAppContext) { throw new InvalidRequestError('Invalid permissions object') } + // Validate granular permissions (aligned with unified model) if ( typeof permissions.read !== 'boolean' || - typeof permissions.write !== 'boolean' + typeof permissions.create !== 'boolean' || + typeof permissions.update !== 'boolean' || + typeof permissions.delete !== 'boolean' ) { throw new InvalidRequestError( - 'Permissions must specify boolean values for read and write', + 'Permissions must specify boolean values for read, create, update, and delete', ) } - // Validate admin permission if provided + // Validate optional role permissions if ( permissions.admin !== undefined && typeof permissions.admin !== 'boolean' @@ -51,37 +53,40 @@ export default function (server: Server, ctx: SdsAppContext) { ) } - // Check if the authenticated user has permission to grant access - // Repository owners and users with admin permissions can grant access - const isOwner = await ctx.permissionManager.isOwner( - repoDid, - grantedByDid, - ) - const hasAdminAccess = await ctx.permissionManager.checkAccess( + if ( + permissions.owner !== undefined && + typeof permissions.owner !== 'boolean' + ) { + throw new InvalidRequestError( + 'Owner permission must be a boolean value', + ) + } + + // Prevent granting to repository itself (although this shouldn't happen in RBAC model) + if (userDid === repoDid) { + throw new InvalidRequestError('Invalid target user DID') + } + + // Prevent granting to yourself + if (userDid === grantedByDid) { + throw new InvalidRequestError('Cannot grant access to yourself') + } + + // Check if granter can grant these specific permissions + const permissionCheck = await ctx.permissionManager.canGrantPermissions( repoDid, grantedByDid, - 'admin', - ) - - console.log( - `[SDS] Grant access permission check - Owner: ${isOwner}, Admin: ${hasAdminAccess}, User: ${grantedByDid}, Repo: ${repoDid}`, + permissions as RepositoryPermissions, ) - if (!isOwner && !hasAdminAccess) { + if (!permissionCheck.canGrant) { throw new AuthRequiredError( - 'Only repository owners and admin users can grant access to collaborators', - ) - } - - // Prevent users from granting access to themselves - if (userDid === repoDid) { - throw new InvalidRequestError( - 'Cannot grant access to repository owner (access is implicit)', + permissionCheck.reason || + 'Insufficient permissions to grant access', ) } // Validate that the target user exists by checking if it's a valid DID - // In a full implementation, you might want to verify the DID exists in your system if (!userDid.startsWith('did:')) { throw new InvalidRequestError('Invalid user DID format') } diff --git a/packages/sds/src/api/com/sds/repo/index.ts b/packages/sds/src/api/com/sds/repo/index.ts index 7b15831428b..ff744f6955d 100644 --- a/packages/sds/src/api/com/sds/repo/index.ts +++ b/packages/sds/src/api/com/sds/repo/index.ts @@ -8,6 +8,7 @@ import grantAccess from './grantAccess' import listCollaborators from './listCollaborators' import putRecord from './putRecord' import revokeAccess from './revokeAccess' +import transferOwnership from './transferOwnership' export default function (server: Server, ctx: SdsAppContext) { // SDS-specific overrides (support shared access) @@ -18,6 +19,7 @@ export default function (server: Server, ctx: SdsAppContext) { grantAccess(server, ctx) revokeAccess(server, ctx) + transferOwnership(server, ctx) listCollaborators(server, ctx) getPermissions(server, ctx) } diff --git a/packages/sds/src/api/com/sds/repo/revokeAccess.ts b/packages/sds/src/api/com/sds/repo/revokeAccess.ts index c2d0cff9334..e3f11f472d4 100644 --- a/packages/sds/src/api/com/sds/repo/revokeAccess.ts +++ b/packages/sds/src/api/com/sds/repo/revokeAccess.ts @@ -1,7 +1,6 @@ // SDS Revoke Access Endpoint - Allows repository owners to revoke access from collaborators import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' -import { ids } from '../../../../lexicon/lexicons' import { SdsAppContext } from '../../../../sds-context' import { SdsPermissionError } from '../../../../types' @@ -28,32 +27,22 @@ export default function (server: Server, ctx: SdsAppContext) { const repoDid = account.did - // Check if the authenticated user has permission to revoke access - // Repository owners and users with admin permissions can revoke access - const isOwner = await ctx.permissionManager.isOwner( - repoDid, - revokedByDid, - ) - const hasAdminAccess = await ctx.permissionManager.checkAccess( + // Prevent revoking from repository itself + if (userDid === repoDid) { + throw new InvalidRequestError('Invalid target user DID') + } + + // Check if revoker can manage the target user + const managementCheck = await ctx.permissionManager.canManageUser( repoDid, revokedByDid, - 'admin', + userDid, ) - console.log( - `[SDS] Revoke access permission check - Owner: ${isOwner}, Admin: ${hasAdminAccess}, User: ${revokedByDid}, Repo: ${repoDid}`, - ) - - if (!isOwner && !hasAdminAccess) { + if (!managementCheck.canManage) { throw new AuthRequiredError( - 'Only repository owners and admin users can revoke access from collaborators', - ) - } - - // Prevent users from revoking access from themselves (doesn't make sense) - if (userDid === repoDid) { - throw new InvalidRequestError( - 'Cannot revoke access from repository owner', + managementCheck.reason || + 'Insufficient permissions to revoke access', ) } diff --git a/packages/sds/src/api/com/sds/repo/transferOwnership.ts b/packages/sds/src/api/com/sds/repo/transferOwnership.ts new file mode 100644 index 00000000000..62bd32981bd --- /dev/null +++ b/packages/sds/src/api/com/sds/repo/transferOwnership.ts @@ -0,0 +1,100 @@ +// SDS Transfer Ownership Endpoint - Allows repository owners to transfer ownership +import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' +import { Server } from '../../../../lexicon' +import { SdsAppContext } from '../../../../sds-context' + +export default function (server: Server, ctx: SdsAppContext) { + server.com.sds.repo.transferOwnership({ + auth: ctx.authVerifier.oauth(), + rateLimit: [ + { + name: 'sds-permission-write', + calcKey: () => 'development', // Development mode - no user-specific limits + calcPoints: () => 1, + }, + ], + handler: async ({ input, auth }) => { + const { repo, newOwnerDid } = input.body + const currentOwnerDid = (auth as any).credentials.did + + try { + const account = await ctx.authVerifier.findAccount(repo, { + checkDeactivated: true, + checkTakedown: true, + }) + const repoDid = account.did + + // Only current owner can transfer ownership + const isOwner = await ctx.permissionManager.isOwner( + repoDid, + currentOwnerDid, + ) + if (!isOwner) { + throw new AuthRequiredError( + 'Only the repository owner can transfer ownership', + ) + } + + // Cannot transfer to yourself + if (newOwnerDid === currentOwnerDid) { + throw new InvalidRequestError('Cannot transfer ownership to yourself') + } + + // Validate new owner DID + if (!newOwnerDid.startsWith('did:')) { + throw new InvalidRequestError('Invalid new owner DID format') + } + + // Grant owner role to new owner + await ctx.permissionManager.grantAccess( + repoDid, + newOwnerDid, + { + read: true, + create: true, + update: true, + delete: true, + admin: true, + owner: true, + }, + currentOwnerDid, + ) + + // Demote current owner to admin + await ctx.permissionManager.grantAccess( + repoDid, + currentOwnerDid, + { + read: true, + create: true, + update: true, + delete: true, + admin: true, + owner: false, + }, + newOwnerDid, // Granted by new owner + ) + + return { + encoding: 'application/json', + body: { + success: true, + previousOwner: currentOwnerDid, + newOwner: newOwnerDid, + transferredAt: new Date().toISOString(), + }, + } + } catch (error) { + if ( + error instanceof InvalidRequestError || + error instanceof AuthRequiredError + ) { + throw error + } + + console.error('Error transferring repository ownership:', error) + throw new InvalidRequestError('Failed to transfer repository ownership') + } + }, + }) +} diff --git a/packages/sds/src/lexicon/index.ts b/packages/sds/src/lexicon/index.ts index dd1bf02f88f..8b3fbdc1d62 100644 --- a/packages/sds/src/lexicon/index.ts +++ b/packages/sds/src/lexicon/index.ts @@ -207,6 +207,7 @@ import * as ComSdsRepoGetPermissions from './types/com/sds/repo/getPermissions.j import * as ComSdsRepoGrantAccess from './types/com/sds/repo/grantAccess.js' import * as ComSdsRepoListCollaborators from './types/com/sds/repo/listCollaborators.js' import * as ComSdsRepoRevokeAccess from './types/com/sds/repo/revokeAccess.js' +import * as ComSdsRepoTransferOwnership from './types/com/sds/repo/transferOwnership.js' import * as ToolsOzoneCommunicationCreateTemplate from './types/tools/ozone/communication/createTemplate.js' import * as ToolsOzoneCommunicationDeleteTemplate from './types/tools/ozone/communication/deleteTemplate.js' import * as ToolsOzoneCommunicationListTemplates from './types/tools/ozone/communication/listTemplates.js' @@ -3055,6 +3056,18 @@ export class ComSdsRepoNS { const nsid = 'com.sds.repo.revokeAccess' // @ts-ignore return this._server.xrpc.method(nsid, cfg) } + + transferOwnership( + cfg: MethodConfigOrHandler< + A, + ComSdsRepoTransferOwnership.QueryParams, + ComSdsRepoTransferOwnership.HandlerInput, + ComSdsRepoTransferOwnership.HandlerOutput + >, + ) { + const nsid = 'com.sds.repo.transferOwnership' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } } export class ToolsNS { diff --git a/packages/sds/src/lexicon/lexicons.ts b/packages/sds/src/lexicon/lexicons.ts index 4f01c5451cc..a2034b927d5 100644 --- a/packages/sds/src/lexicon/lexicons.ts +++ b/packages/sds/src/lexicon/lexicons.ts @@ -14138,6 +14138,11 @@ export const schemaDict = { description: 'Administrative permissions (manage collaborators, etc.).', }, + owner: { + type: 'boolean', + description: + 'Owner permissions (full control including ownership transfer).', + }, }, }, collaboratorInfo: { @@ -14313,6 +14318,66 @@ export const schemaDict = { }, }, }, + ComSdsRepoTransferOwnership: { + lexicon: 1, + id: 'com.sds.repo.transferOwnership', + defs: { + main: { + type: 'procedure', + description: + 'Transfer repository ownership to another user. Only the current owner can perform this operation.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['repo', 'newOwnerDid'], + properties: { + repo: { + type: 'string', + format: 'at-identifier', + description: 'The handle or DID of the repository.', + }, + newOwnerDid: { + type: 'string', + format: 'did', + description: 'The DID of the new owner.', + }, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['success', 'previousOwner', 'newOwner', 'transferredAt'], + properties: { + success: { + type: 'boolean', + }, + previousOwner: { + type: 'string', + format: 'did', + }, + newOwner: { + type: 'string', + format: 'did', + }, + transferredAt: { + type: 'string', + format: 'datetime', + }, + }, + }, + }, + errors: [ + { + name: 'Unauthorized', + description: 'Only the repository owner can transfer ownership.', + }, + ], + }, + }, + }, ToolsOzoneCommunicationCreateTemplate: { lexicon: 1, id: 'tools.ozone.communication.createTemplate', @@ -19190,6 +19255,7 @@ export const ids = { ComSdsRepoGrantAccess: 'com.sds.repo.grantAccess', ComSdsRepoListCollaborators: 'com.sds.repo.listCollaborators', ComSdsRepoRevokeAccess: 'com.sds.repo.revokeAccess', + ComSdsRepoTransferOwnership: 'com.sds.repo.transferOwnership', ToolsOzoneCommunicationCreateTemplate: 'tools.ozone.communication.createTemplate', ToolsOzoneCommunicationDefs: 'tools.ozone.communication.defs', diff --git a/packages/sds/src/lexicon/types/com/sds/repo/grantAccess.ts b/packages/sds/src/lexicon/types/com/sds/repo/grantAccess.ts index ee9b1887e71..873700352f4 100644 --- a/packages/sds/src/lexicon/types/com/sds/repo/grantAccess.ts +++ b/packages/sds/src/lexicon/types/com/sds/repo/grantAccess.ts @@ -55,15 +55,21 @@ export interface HandlerError { export type HandlerOutput = HandlerError | HandlerSuccess -/** Repository access permissions */ +/** Repository access permissions aligned with OAuth's granular action model */ export interface Permissions { $type?: 'com.sds.repo.grantAccess#permissions' /** Permission to read repository content. */ read: boolean - /** Permission to write/modify repository content. */ - write: boolean + /** Permission to create new records in the repository. */ + create: boolean + /** Permission to update existing records in the repository. */ + update: boolean + /** Permission to delete records from the repository. */ + delete: boolean /** Administrative permissions (manage collaborators, etc.). */ admin?: boolean + /** Owner permissions (full control including ownership transfer). */ + owner?: boolean } const hashPermissions = 'permissions' diff --git a/packages/sds/src/lexicon/types/com/sds/repo/transferOwnership.ts b/packages/sds/src/lexicon/types/com/sds/repo/transferOwnership.ts new file mode 100644 index 00000000000..4d400cf4f62 --- /dev/null +++ b/packages/sds/src/lexicon/types/com/sds/repo/transferOwnership.ts @@ -0,0 +1,50 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { type ValidationResult, BlobRef } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' +import { validate as _validate } from '../../../../lexicons' +import { + type $Typed, + is$typed as _is$typed, + type OmitKey, +} from '../../../../util' + +const is$typed = _is$typed, + validate = _validate +const id = 'com.sds.repo.transferOwnership' + +export type QueryParams = {} + +export interface InputSchema { + /** The handle or DID of the repository. */ + repo: string + /** The DID of the new owner. */ + newOwnerDid: string +} + +export interface OutputSchema { + success: boolean + previousOwner: string + newOwner: string + transferredAt: string +} + +export interface HandlerInput { + encoding: 'application/json' + body: InputSchema +} + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string + error?: 'Unauthorized' +} + +export type HandlerOutput = HandlerError | HandlerSuccess diff --git a/packages/sds/src/permission-manager/index.ts b/packages/sds/src/permission-manager/index.ts index 7c3a4d26fbc..9d07125553f 100644 --- a/packages/sds/src/permission-manager/index.ts +++ b/packages/sds/src/permission-manager/index.ts @@ -20,17 +20,22 @@ export class SdsPermissionManager { action: keyof RepositoryPermissions, ): Promise { try { - // First check if the user is the owner of the repository - const isOwner = await this.isOwner(repoDid, userDid) - if (isOwner) { - // Owners always have full access to their repositories + const permissions = await this.getPermissions(repoDid, userDid) + if (!permissions) return false + + // Owners have implicit access to everything + if (permissions.owner === true) { return true } - // If not owner, check for explicit permissions - const permissions = await this.getPermissions(repoDid, userDid) - if (!permissions) return false + // Admins have implicit access to all CRUD operations + if (permissions.admin === true) { + if (['read', 'create', 'update', 'delete', 'admin'].includes(action)) { + return true + } + } + // Otherwise check specific permission return permissions[action] ?? false } catch (error) { console.error('Error checking repository access:', error) @@ -51,6 +56,108 @@ export class SdsPermissionManager { } } + /** + * Get the role of a user for a repository + */ + async getUserRole( + repoDid: string, + userDid: string, + ): Promise<'owner' | 'admin' | 'collaborator' | 'none'> { + try { + const permissions = await this.getPermissions(repoDid, userDid) + if (!permissions) return 'none' + + if (permissions.owner === true) return 'owner' + if (permissions.admin === true) return 'admin' + + if ( + permissions.read || + permissions.create || + permissions.update || + permissions.delete + ) { + return 'collaborator' + } + + return 'none' + } catch (error) { + console.error('Error getting user role:', error) + return 'none' + } + } + + /** + * Check if user can manage a target user + * - Owner can manage anyone + * - Admin can manage other admins and collaborators (but not owner) + * - Collaborators cannot manage anyone + */ + async canManageUser( + repoDid: string, + managerDid: string, + targetDid: string, + ): Promise<{ canManage: boolean; reason?: string }> { + try { + if (managerDid === targetDid) { + return { canManage: false, reason: 'Cannot manage yourself' } + } + + const managerRole = await this.getUserRole(repoDid, managerDid) + const targetRole = await this.getUserRole(repoDid, targetDid) + + if (managerRole === 'owner') { + return { canManage: true } + } + + if (managerRole === 'admin') { + if (targetRole === 'owner') { + return { + canManage: false, + reason: 'Admins cannot manage the owner', + } + } + return { canManage: true } + } + + return { canManage: false, reason: 'Insufficient permissions' } + } catch (error) { + return { canManage: false, reason: 'Error checking permissions' } + } + } + + /** + * Check if user can grant specific permissions + * - Only owner can grant owner or admin roles + * - Admin can grant collaborator permissions only + */ + async canGrantPermissions( + repoDid: string, + granterDid: string, + permissionsToGrant: RepositoryPermissions, + ): Promise<{ canGrant: boolean; reason?: string }> { + const granterRole = await this.getUserRole(repoDid, granterDid) + + if (permissionsToGrant.owner === true && granterRole !== 'owner') { + return { + canGrant: false, + reason: 'Only repository owner can grant owner role', + } + } + + if (permissionsToGrant.admin === true && granterRole !== 'owner') { + return { + canGrant: false, + reason: 'Only repository owner can grant admin role', + } + } + + if (granterRole === 'owner' || granterRole === 'admin') { + return { canGrant: true } + } + + return { canGrant: false, reason: 'Insufficient permissions' } + } + /** * Grant access to a repository for a user */ diff --git a/packages/sds/src/sds-auth-verifier.ts b/packages/sds/src/sds-auth-verifier.ts index cf2031e31a8..8f85f2e1ec0 100644 --- a/packages/sds/src/sds-auth-verifier.ts +++ b/packages/sds/src/sds-auth-verifier.ts @@ -325,6 +325,9 @@ export class SdsAuthVerifier extends AuthVerifier { /** * Utility method to determine required action based on request context * This helps endpoints determine what permission to check based on the operation + * + * Note: This is a legacy method. Endpoints should explicitly check for the + * specific granular permissions (create, update, delete) they require. */ getRequiredAction( method: string, @@ -340,20 +343,35 @@ export class SdsAuthVerifier extends AuthVerifier { return 'admin' } - // Write operations - if (method === 'POST' || method === 'PUT' || method === 'DELETE') { - return 'write' + // Granular write operations based on specific endpoints + if (path?.includes('createRecord')) { + return 'create' } - // Specific endpoint patterns for write operations - if ( - path?.includes('createRecord') || - path?.includes('putRecord') || - path?.includes('deleteRecord') || - path?.includes('uploadBlob') || - path?.includes('applyWrites') - ) { - return 'write' + if (path?.includes('putRecord')) { + return 'update' + } + + if (path?.includes('deleteRecord')) { + return 'delete' + } + + // applyWrites needs to check per-write-type, default to create + if (path?.includes('applyWrites') || path?.includes('uploadBlob')) { + return 'create' + } + + // Method-based inference (less precise) + if (method === 'POST') { + return 'create' + } + + if (method === 'PUT') { + return 'update' + } + + if (method === 'DELETE') { + return 'delete' } // Default to read for GET operations and other cases diff --git a/packages/sds/src/types.ts b/packages/sds/src/types.ts index fd1f56d5b4c..f9ac2490e18 100644 --- a/packages/sds/src/types.ts +++ b/packages/sds/src/types.ts @@ -13,6 +13,9 @@ export interface RepositoryPermissions { owner?: boolean } +// Role hierarchy for permission checks +export type RepositoryRole = 'owner' | 'admin' | 'collaborator' | 'none' + export interface SharingConfig { maxCollaborators: number enableAuditLog: boolean From 9fef7cdfd08d71d35fad4b2901f52736b7d5e702 Mon Sep 17 00:00:00 2001 From: bitbeckers Date: Thu, 6 Nov 2025 22:34:45 -0300 Subject: [PATCH 6/6] feat: refactor permissions to granular CRUD model - Replace read/write permissions with read/create/update/delete/admin/owner - Update permission badges to display granular permissions - Normalize permissions in organization list API and dashboard - Enhance repository card to show detailed permission badges - Update collaboration service to handle new permission structure --- .../src/components/permission-badge.tsx | 88 +++++++------ .../src/components/repository-card.tsx | 61 +++++---- .../src/components/repository-dashboard.tsx | 117 ++++++++++++------ .../src/contexts/repository-context.tsx | 3 +- packages/sds-demo/src/main.tsx | 6 +- .../sds-demo/src/queries/use-sds-queries.ts | 41 ++++-- .../src/services/collaboration-service.ts | 26 +++- .../sds/src/api/com/sds/organization/list.ts | 42 +++++-- 8 files changed, 252 insertions(+), 132 deletions(-) diff --git a/packages/sds-demo/src/components/permission-badge.tsx b/packages/sds-demo/src/components/permission-badge.tsx index 128d9eac8bc..3c53b6f1a6a 100644 --- a/packages/sds-demo/src/components/permission-badge.tsx +++ b/packages/sds-demo/src/components/permission-badge.tsx @@ -29,6 +29,7 @@ export function PermissionBadge({ Owner: 'bg-red-100 text-red-800', Admin: 'bg-purple-100 text-purple-800', 'Read & Write': 'bg-green-100 text-green-800', + 'Write Only': 'bg-amber-100 text-amber-800', 'Read Only': 'bg-blue-100 text-blue-800', 'No Access': 'bg-gray-100 text-gray-800', } @@ -48,48 +49,59 @@ export function DetailedPermissionBadges({ permissions, className = '', }: DetailedPermissionBadgesProps) { + const permissionEntries: Array< + { label: string; value: boolean } & { + highlight?: 'primary' | 'warning' + } + > = [ + { label: 'Read', value: permissions.read, highlight: 'primary' }, + { label: 'Create', value: permissions.create, highlight: 'primary' }, + { label: 'Update', value: permissions.update, highlight: 'primary' }, + { label: 'Delete', value: permissions.delete, highlight: 'primary' }, + ] + + if (permissions.admin !== undefined) { + permissionEntries.push({ + label: 'Admin', + value: permissions.admin, + highlight: 'warning', + }) + } + + if (permissions.owner !== undefined) { + permissionEntries.push({ + label: 'Owner', + value: permissions.owner, + highlight: 'warning', + }) + } + + const getBadgeClasses = ( + value: boolean, + highlight: 'primary' | 'warning' = 'primary', + ) => { + if (value) { + return highlight === 'primary' + ? 'bg-green-100 text-green-700' + : 'bg-purple-100 text-purple-700' + } + + return 'bg-red-100 text-red-700' + } + return ( -
- - Read: {permissions.read ? '✓' : '✗'} - - - Write: {permissions.write ? '✓' : '✗'} - - {permissions.admin !== undefined && ( - - Admin: {permissions.admin ? '✓' : '✗'} - - )} - {permissions.owner !== undefined && ( +
+ {permissionEntries.map(({ label, value, highlight }) => ( - Owner: {permissions.owner ? '✓' : '✗'} + {label}: {value ? '✓' : '✗'} - )} + ))}
) } diff --git a/packages/sds-demo/src/components/repository-card.tsx b/packages/sds-demo/src/components/repository-card.tsx index 9b19a742864..e7e1da4f813 100644 --- a/packages/sds-demo/src/components/repository-card.tsx +++ b/packages/sds-demo/src/components/repository-card.tsx @@ -2,6 +2,10 @@ import { Repository } from '../contexts/repository-context.tsx' import { useListCollaboratorsQuery } from '../queries/use-collaboration-queries.ts' import { Button } from './button.tsx' +import { + DetailedPermissionBadges, + PermissionBadge, +} from './permission-badge.tsx' interface RepositoryCardProps { repository: Repository @@ -16,11 +20,13 @@ export function RepositoryCard({ onSelect, onManageCollaborators, }: RepositoryCardProps) { - // Query to get collaborator count for this repository - // Only enable for owners to reduce simultaneous API calls on mount + const canManage = Boolean( + repository.permissions?.owner || repository.permissions?.admin, + ) + const collaboratorsQuery = useListCollaboratorsQuery( repository.did, - repository.accessType === 'owner', + canManage, ) const collaboratorCount = collaboratorsQuery.data?.collaborators?.length || 0 @@ -34,12 +40,20 @@ export function RepositoryCard({ > {/* Repository Header */}
-

- {repository.handle} -

+
+

+ {repository.handle} +

+ {repository.permissions && ( + + )} +
{/* Permissions Display */} -
-
- - Read: {repository.permissions?.read ? '✓' : '✗'} - - - Write: {repository.permissions?.write ? '✓' : '✗'} - -
-
+ {repository.permissions && ( + + )} {/* Collaborator Info and Management */}
@@ -95,8 +90,8 @@ export function RepositoryCard({ : 'No collaborators'} - {/* Manage Collaborators Button - Only show for owners */} - {repository.accessType === 'owner' && ( + {/* Manage Collaborators Button - Owners & Admins */} + {canManage && (