Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion src/api/app-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,9 +401,30 @@ export function createAppHelpers(deps: AppDeps, app: App) {
const membershipControlsScope = createMembershipControlsScope(scopeMembershipDeps);

async function authorizesCapabilityScope(
claims: Pick<CapabilityClaims, "actorId" | "scopeId" | "scopeVersion">,
claims: Pick<CapabilityClaims, "actorId" | "scopeId" | "scopeVersion" | "botActor" | "liveActor" | "members">,
): Promise<boolean> {
const { kind, ref } = parseScopeId(claims.scopeId);
if (kind === "channel" && !deps.identity.isInternal(deps.identity.classify(claims.actorId))) return false;
const privateChannel =
kind === "channel" && (await deps.directory.channelPrivacy?.(ref).catch(() => undefined)) === true;
const capabilityMembership = privateChannel
? await deps.directory.channelMembership(ref, claims.actorId).catch(() => undefined)
: undefined;
const attestedBot =
privateChannel &&
claims.botActor === true &&
claims.liveActor === true &&
claims.members?.some((member) => member.id === claims.actorId && member.type === "internal") === true;
if (
(kind === "channel" &&
!(
attestedBot ||
(capabilityMembership ?? (await principalCanAccessCurrentScope(claims.actorId, claims.scopeId)))
)) ||
(kind === "group" && !(await principalCanWriteScope(claims.actorId, claims.scopeId)))
) {
return false;
}
if (kind !== "group" || deps.projects?.recognizes(ref) !== true) return true;
return (
(await principalCanManageScope(claims.actorId, claims.scopeId)) &&
Expand Down
8 changes: 4 additions & 4 deletions src/api/app-messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,12 +339,12 @@ export function createMessagingMethods(
});
}
},
async upsertChannels(channels, channelMembers, syncedAt) {
await deps.directory.replaceChannels(channels, channelMembers, syncedAt);
async upsertChannels(channels, channelMembers, syncedAt, channelRosterIds, revocations) {
await deps.directory.replaceChannels(channels, channelMembers, syncedAt, channelRosterIds, revocations);
await h.syncLinkedProjectRosters();
},
async upsertGroups(groupMembers, syncedAt) {
await deps.directory.replaceGroups(groupMembers, syncedAt);
async upsertGroups(groupMembers, syncedAt, groupIds, groupRosterIds) {
await deps.directory.replaceGroups(groupMembers, syncedAt, groupIds, groupRosterIds);
},
async setDirectoryWorkspaceUrl(url) {
await deps.directory.setWorkspaceUrl(url);
Expand Down
7 changes: 4 additions & 3 deletions src/api/app-turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ export function createTurnMethods(
async turn(req: TurnRequest): Promise<TurnResult> {
await deps.identity.refresh();
const actor: Principal = deps.identity.resolve(req.actor);
if (!deps.identity.isInternal(actor)) {
return { status: "refused", reason: "internal-only: non-internal principals cannot interact" };
}
let projectAudience: Principal[] | undefined;
let projectName: string | undefined;
let projectVersion: string | undefined;
Expand All @@ -66,9 +69,6 @@ export function createTurnMethods(
const projectId = projectGroup ? projectIdFromGroupRef(conversationRef) : null;

if (projectGroup) {
if (!deps.identity.isInternal(actor)) {
return { status: "refused", reason: "you're not a member of that context" };
}
if (!projectId) return { status: "refused", reason: "you're not a member of that context" };
const project = await deps.projects?.get(projectId);
if (
Expand Down Expand Up @@ -243,6 +243,7 @@ export function createTurnMethods(
...(req.readOnly ? { readOnly: true } : {}),
...(req.skipMemory ? { skipMemory: true } : {}),
...(req.unattendedGrants?.length ? { unattendedGrants: req.unattendedGrants } : {}),
...(req.botActor ? { botActor: true } : {}),
...(req.surfaceTools ? { surfaceTools: true } : {}),
...(req.envelopeWrapped ? { envelopeWrapped: true } : {}),
...(typeof req.displayText === "string" && req.displayText ? { displayText: req.displayText } : {}),
Expand Down
19 changes: 16 additions & 3 deletions src/api/app-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,9 @@ export interface App {
listScopeResources(principalId: string, scope: ScopeId): Promise<ScopeResources | null>;
managesScope(principalId: string, scope: ScopeId): Promise<boolean>;
membershipControlsScope(scope: ScopeId): Promise<boolean>;
authorizesCapabilityScope(claims: Pick<CapabilityClaims, "actorId" | "scopeId" | "scopeVersion">): Promise<boolean>;
authorizesCapabilityScope(
claims: Pick<CapabilityClaims, "actorId" | "scopeId" | "scopeVersion" | "botActor" | "liveActor" | "members">,
): Promise<boolean>;
openFileForViewer(id: string, principalId: string): Promise<OpenedFile | null>;
grant(g: Grant): Promise<void>;
revokeGrant(ownerScopeId: ScopeId, ref: string, granteeScopeId: ScopeId, revokedBy: string): Promise<void>;
Expand Down Expand Up @@ -386,8 +388,19 @@ export interface App {
ackDeliveryByKey(idempotencyKey: string): Promise<void>;
setRunDeliveryState(runId: string, state: RunDeliveryState): Promise<boolean>;
upsertDirectory(members: DirectoryMember[], syncedAt?: number): Promise<void>;
upsertChannels(channels: DirectoryChannel[], channelMembers?: ChannelMembership[], syncedAt?: number): Promise<void>;
upsertGroups(groupMembers: GroupMembership[], syncedAt?: number): Promise<void>;
upsertChannels(
channels: DirectoryChannel[],
channelMembers?: ChannelMembership[],
syncedAt?: number,
channelRosterIds?: string[],
revocations?: ChannelMembership[],
): Promise<void>;
upsertGroups(
groupMembers: GroupMembership[],
syncedAt?: number,
groupIds?: string[],
groupRosterIds?: string[],
): Promise<void>;
setDirectoryWorkspaceUrl(url: string): Promise<void>;
directoryMeta(): Promise<DirectoryMeta>;
resolveRecipient(query: string): Promise<RecipientResolution>;
Expand Down
3 changes: 3 additions & 0 deletions src/api/git-http-broker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ export async function brokerGitHttp(ctx: BaseCtx): Promise<void> {
actorId: claims.actorId,
scopeId: claims.scopeId,
...(claims.scopeVersion ? { scopeVersion: claims.scopeVersion } : {}),
...(claims.botActor ? { botActor: true } : {}),
...(claims.liveActor ? { liveActor: true } : {}),
...(claims.members ? { members: claims.members } : {}),
}))
) {
return sendJson(ctx.res, 403, { error: "forbidden", message: "capability scope membership has been revoked" });
Expand Down
3 changes: 3 additions & 0 deletions src/api/routes/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,9 @@ function awaitFileFulfillment(ctx: ApiCtx, requestId: string): Promise<void> {
actorId: cap.actorId,
scopeId: cap.scopeId,
...(cap.scopeVersion ? { scopeVersion: cap.scopeVersion } : {}),
...(cap.botActor ? { botActor: true } : {}),
...(cap.liveActor ? { liveActor: true } : {}),
...(cap.members ? { members: cap.members } : {}),
aud: BLOB_TRANSFER_AUD,
blob: { dir: "read", id: file.blobId },
exp: Date.now() + FILE_DOWNLOAD_TTL_MS,
Expand Down
31 changes: 28 additions & 3 deletions src/api/routes/directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ async function pushDirectory(ctx: ApiCtx): Promise<void> {
members?: unknown;
channels?: unknown;
channelMembers?: unknown;
channelRosterIds?: unknown;
channelRevocations?: unknown;
groupMembers?: unknown;
groupIds?: unknown;
groupRosterIds?: unknown;
workspaceUrl?: unknown;
membersSyncedAt?: unknown;
channelsSyncedAt?: unknown;
Expand Down Expand Up @@ -69,7 +73,7 @@ async function pushDirectory(ctx: ApiCtx): Promise<void> {
let channelCount: number | undefined;
if (Array.isArray(b.channels)) {
const channels = b.channels.filter(
(c): c is { channelId: string; name: string; isPrivate?: boolean } =>
(c): c is { channelId: string; name: string; isPrivate?: boolean; isExternal?: boolean } =>
isObj(c) && typeof c.channelId === "string" && typeof c.name === "string",
);
const channelMembers = Array.isArray(b.channelMembers)
Expand All @@ -78,7 +82,22 @@ async function pushDirectory(ctx: ApiCtx): Promise<void> {
isObj(m) && typeof m.channelId === "string" && typeof m.principalId === "string",
)
: undefined;
await app.upsertChannels(channels, channelMembers, numOrUndef(b.channelsSyncedAt));
const channelRosterIds = Array.isArray(b.channelRosterIds)
? b.channelRosterIds.filter((channelId): channelId is string => typeof channelId === "string")
: undefined;
const channelRevocations = Array.isArray(b.channelRevocations)
? b.channelRevocations.filter(
(m): m is { channelId: string; principalId: string } =>
isObj(m) && typeof m.channelId === "string" && typeof m.principalId === "string",
)
: undefined;
await app.upsertChannels(
channels,
channelMembers,
numOrUndef(b.channelsSyncedAt),
channelRosterIds,
channelRevocations,
);
channelCount = channels.length;
}
let groupMemberCount: number | undefined;
Expand All @@ -87,7 +106,13 @@ async function pushDirectory(ctx: ApiCtx): Promise<void> {
(m): m is { groupId: string; principalId: string } =>
isObj(m) && typeof m.groupId === "string" && typeof m.principalId === "string",
);
await app.upsertGroups(groupMembers, numOrUndef(b.groupsSyncedAt));
const groupIds = Array.isArray(b.groupIds)
? b.groupIds.filter((groupId): groupId is string => typeof groupId === "string")
: undefined;
const groupRosterIds = Array.isArray(b.groupRosterIds)
? b.groupRosterIds.filter((groupId): groupId is string => typeof groupId === "string")
: undefined;
await app.upsertGroups(groupMembers, numOrUndef(b.groupsSyncedAt), groupIds, groupRosterIds);
groupMemberCount = groupMembers.length;
}
return sendJson(res, 200, {
Expand Down
45 changes: 34 additions & 11 deletions src/api/routes/secret-drop.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { orgId as configOrgId } from "../../config.ts";
import { mintCapabilityToken, verifyCapabilityToken, SECRET_DROP_AUD } from "../../auth/capability-token.ts";
import {
mintCapabilityToken,
verifyCapabilityToken,
SECRET_DROP_AUD,
type CapabilityClaims,
} from "../../auth/capability-token.ts";
import { KeychainError, type GrantMode } from "../../credentials/keychain.ts";
import { SECRET_DROP_TTL_MS, type SecretDropField, type SecretDropRecord } from "../../credentials/secret-drop.ts";
import { isSharedScope, parseScopeId } from "../../types.ts";
Expand Down Expand Up @@ -43,27 +48,40 @@ function dropNotYoursHtml(): string {
<h2>This link is for someone else</h2><p>This credential request was created for a different teammate. If it was meant for you, sign in as yourself and open it again.</p></body>`;
}

function dropScopeAuthorized(ctx: ApiCtx, rec: SecretDropRecord): Promise<boolean> {
function dropScopeAuthorized(ctx: ApiCtx, rec: SecretDropRecord, claims?: CapabilityClaims): Promise<boolean> {
const audienceScopeId = rec.audienceScopeId;
if (!audienceScopeId || !isSharedScope(audienceScopeId)) return Promise.resolve(true);
return ctx.app
.authorizesCapabilityScope({
actorId: rec.ownerId,
scopeId: audienceScopeId,
...(rec.scopeVersion ? { scopeVersion: rec.scopeVersion } : {}),
...(claims?.botActor ? { botActor: true } : {}),
...(claims?.liveActor ? { liveActor: true } : {}),
...(claims?.members ? { members: claims.members } : {}),
})
.catch(() => false);
}

async function dropLinkTokenOk(ctx: ApiCtx, dropId: string, rec: SecretDropRecord): Promise<boolean> {
async function dropLinkClaims(
ctx: ApiCtx,
dropId: string,
rec: SecretDropRecord,
): Promise<CapabilityClaims | true | null> {
if (!rec.requiresToken) return true;
const capSecret = ctx.deps.capabilitySecret ?? ctx.secret;
const token = ctx.url.searchParams.get("t");
if (!capSecret || !token) return false;
if (!capSecret || !token) return null;
const claims = await verifyCapabilityToken(token, capSecret);
return (
!!claims && claims.aud === SECRET_DROP_AUD && claims.drop === dropId && samePerson(claims.actorId, rec.ownerId)
);
if (
!claims ||
claims.aud !== SECRET_DROP_AUD ||
claims.drop !== dropId ||
!samePerson(claims.actorId, rec.ownerId) ||
(rec.audienceScopeId && claims.scopeId !== rec.audienceScopeId)
)
return null;
return claims;
}

function dropFormHtml(
Expand Down Expand Up @@ -165,6 +183,9 @@ async function mintDrop(ctx: ApiCtx): Promise<void> {
{
actorId: capability.actorId,
scopeId: capability.scopeId,
...(capability.botActor ? { botActor: true } : {}),
...(capability.liveActor ? { liveActor: true } : {}),
...(capability.members ? { members: capability.members } : {}),
aud: SECRET_DROP_AUD,
drop: dropId,
exp: Date.now() + SECRET_DROP_TTL_MS,
Expand All @@ -183,7 +204,7 @@ async function dropForm(ctx: ApiCtx): Promise<void> {
res.writeHead(404, { "content-type": "text/html; charset=utf-8" });
return void res.end(dropFormHtml(params.id!, null));
}
if (!(await dropLinkTokenOk(ctx, params.id!, peeked.rec))) {
if (!(await dropLinkClaims(ctx, params.id!, peeked.rec))) {
res.writeHead(404, { "content-type": "text/html; charset=utf-8" });
return void res.end(dropFormHtml(params.id!, null));
}
Expand Down Expand Up @@ -213,7 +234,8 @@ async function redeemDrop(ctx: ApiCtx): Promise<void> {
: "this drop link is invalid or was already used — ask the agent for a fresh one";
return sendJson(res, 404, { error: "not_found", message });
}
if (!(await dropLinkTokenOk(ctx, params.id!, peeked.rec))) {
const linkClaims = await dropLinkClaims(ctx, params.id!, peeked.rec);
if (!linkClaims) {
return sendJson(res, 404, {
error: "not_found",
message: "this drop link is invalid or was already used — ask the agent for a fresh one",
Expand All @@ -225,7 +247,8 @@ async function redeemDrop(ctx: ApiCtx): Promise<void> {
message: "sign in as the account owner to complete this credential drop",
});
}
if (!(await dropScopeAuthorized(ctx, peeked.rec))) {
const attestation = linkClaims === true ? undefined : linkClaims;
if (!(await dropScopeAuthorized(ctx, peeked.rec, attestation))) {
await deps.secretDrops.redeem(params.id!).catch(() => null);
return sendJson(res, 409, {
error: "scope_changed",
Expand Down Expand Up @@ -266,7 +289,7 @@ async function redeemDrop(ctx: ApiCtx): Promise<void> {
...(drop.host ? { host: drop.host } : {}),
origin: "secret-drop",
});
const mayShare = await dropScopeAuthorized(ctx, drop);
const mayShare = await dropScopeAuthorized(ctx, drop, attestation);
let grantId: string | undefined;
if (mayShare && drop.grantMode && drop.audienceScopeId) {
const grant = await deps.keychain.createGrant({
Expand Down
3 changes: 3 additions & 0 deletions src/api/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ async function gate(
actorId: capability.actorId,
scopeId: capability.scopeId,
...(capability.scopeVersion ? { scopeVersion: capability.scopeVersion } : {}),
...(capability.botActor ? { botActor: true } : {}),
...(capability.liveActor ? { liveActor: true } : {}),
...(capability.members ? { members: capability.members } : {}),
}))
) {
sendJson(res, 403, { error: "forbidden", message: "capability scope membership has been revoked" });
Expand Down
18 changes: 15 additions & 3 deletions src/api/slack-core-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,13 @@ interface StoredApprovalView {

interface DirectoryPush {
members?: Array<{ principalId: string; displayName: string; type: "internal"; slackId?: string }>;
channels?: Array<{ channelId: string; name: string; isPrivate?: boolean }>;
channels?: Array<{ channelId: string; name: string; isPrivate?: boolean; isExternal?: boolean }>;
channelMembers?: Array<{ channelId: string; principalId: string }>;
channelRosterIds?: string[];
channelRevocations?: Array<{ channelId: string; principalId: string }>;
groupMembers?: Array<{ groupId: string; principalId: string }>;
groupIds?: string[];
groupRosterIds?: string[];
workspaceUrl?: string;
membersSyncedAt?: number;
channelsSyncedAt?: number;
Expand Down Expand Up @@ -302,8 +306,16 @@ export function createSlackCoreClient(deps: SlackCoreClientDeps): SlackCoreClien
async pushDirectory(body) {
if (body.workspaceUrl) await deps.app.setDirectoryWorkspaceUrl(body.workspaceUrl);
if (body.members) await deps.app.upsertDirectory(body.members, body.membersSyncedAt);
if (body.channels) await deps.app.upsertChannels(body.channels, body.channelMembers, body.channelsSyncedAt);
if (body.groupMembers) await deps.app.upsertGroups(body.groupMembers, body.groupsSyncedAt);
if (body.channels)
await deps.app.upsertChannels(
body.channels,
body.channelMembers,
body.channelsSyncedAt,
body.channelRosterIds,
body.channelRevocations,
);
if (body.groupMembers)
await deps.app.upsertGroups(body.groupMembers, body.groupsSyncedAt, body.groupIds, body.groupRosterIds);
},

claimDeliveries(type, claimMs) {
Expand Down
2 changes: 2 additions & 0 deletions src/auth/capability-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface CapabilityClaims {
drop?: string;
memory?: { write?: ScopeId; orgWrite?: ScopeId; read: ScopeId[] };
liveActor?: boolean;
botActor?: boolean;
liveAuthor?: boolean;
triggered?: boolean;
grants?: string[];
Expand Down Expand Up @@ -84,6 +85,7 @@ export async function verifyCapabilityToken(
if (claims.keychainMembers !== undefined && !Array.isArray(claims.keychainMembers)) return null;
if (claims.memory !== undefined && !Array.isArray(claims.memory?.read)) return null;
if (claims.liveActor !== undefined && typeof claims.liveActor !== "boolean") return null;
if (claims.botActor !== undefined && typeof claims.botActor !== "boolean") return null;
if (claims.liveAuthor !== undefined && typeof claims.liveAuthor !== "boolean") return null;
if (claims.blob !== undefined && claims.blob?.dir !== "read" && claims.blob?.dir !== "write") return null;
if (claims.drop !== undefined && typeof claims.drop !== "string") return null;
Expand Down
Loading