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
2 changes: 1 addition & 1 deletion packages/owletto
17 changes: 9 additions & 8 deletions packages/server/src/auth/tool-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ const MEMBER_WRITE_ACTIONS: Record<string, Set<string> | null> = {
// these tables to fire the same per-action access decisions.
manage_entity: new Set(['create', 'update', 'link', 'unlink', 'update_link']),
// Members can install connections that bind to their own OAuth account
// grant. The handler gates `app_auth_profile_slug` overrides against the
// org default + caller role, so members can't pick a non-default app
// profile.
manage_connections: new Set(['create', 'reauthenticate']),
// grant. `update` is here so members can rebind their own connection's
// auth profile / display name / device pin; the handler enforces
// `created_by === ctx.userId` plus the same per-field role gates as
// create (app_auth_profile pinned-default, target-profile ownership).
manage_connections: new Set(['create', 'update', 'reauthenticate']),
// Members create / reconnect their own oauth_account profile. The handler
// gates `profile_kind` against role so env / oauth_app / browser_session
// stay admin-only.
Expand All @@ -42,10 +43,10 @@ const OWNER_ADMIN_ACTIONS: Record<string, Set<string>> = {
manage_entity: new Set(['delete']),
manage_entity_schema: new Set(['create', 'update', 'delete', 'add_rule', 'remove_rule']),
manage_connections: new Set([
// `create` and `reauthenticate` are in MEMBER_WRITE_ACTIONS — members
// install their own connections (handler enforces app_auth_profile slug
// override + role gates).
'update',
// `create`, `update`, `reauthenticate` are in MEMBER_WRITE_ACTIONS —
// members install / edit their own connections (handler enforces
// created_by === ctx.userId + app_auth_profile slug override + role
// gates).
'delete',
'connect',
'test',
Expand Down
69 changes: 68 additions & 1 deletion packages/server/src/tools/admin/manage_connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1623,7 +1623,7 @@ async function handleUpdate(

// Verify ownership
const existingRows = await sql`
SELECT c.id, c.connector_key, c.auth_profile_id, c.app_auth_profile_id, cd.auth_schema, cd.feeds_schema
SELECT c.id, c.connector_key, c.auth_profile_id, c.app_auth_profile_id, c.created_by, cd.auth_schema, cd.feeds_schema
FROM connections c
LEFT JOIN LATERAL (
SELECT auth_schema, feeds_schema
Expand All @@ -1649,12 +1649,56 @@ async function handleUpdate(
feeds_schema: Record<string, unknown> | null;
auth_profile_id: number | null;
app_auth_profile_id: number | null;
created_by: string | null;
};

const hasAuthProfileArg = Object.hasOwn(args, 'auth_profile_slug');
const hasAppAuthProfileArg = Object.hasOwn(args, 'app_auth_profile_slug');
const hasDeviceWorkerArg = Object.hasOwn(args, 'device_worker_id');

// `update` is now member-writable so members can edit their own
// connection. Resolve the caller's role once up front and gate every
// member action on "I created this connection" — admins/owners are
// unrestricted.
const callerRole = ctx.userId
? await getWorkspaceRole(sql, organizationId, ctx.userId)
: null;
const callerIsAdmin = callerRole === 'admin' || callerRole === 'owner';

if (!callerIsAdmin) {
if (!ctx.userId || existing.created_by !== ctx.userId) {
return {
error: 'You can only update connections you created.',
};
}
}

// App profile updates: non-admins may only set the connector's pinned
// default (mirrors handleCreate's gate). Clearing the app profile is
// admin-only — otherwise a member could strip the org default off a
// shared connection.
if (hasAppAuthProfileArg && !callerIsAdmin) {
const slug = args.app_auth_profile_slug;
if (!slug) {
return { error: 'Only admins can clear the OAuth app profile.' };
}
const picked = await getAuthProfileBySlug(organizationId, slug);
const pinned =
picked?.profile_kind === 'oauth_app' &&
picked.is_default_for_connector &&
picked.connector_key === existing.connector_key;
if (!pinned) {
return {
error: `Only admins can override the OAuth app profile. Ask an admin to pin '${slug}' as the default for this connector, or omit app_auth_profile_slug to use the org default.`,
};
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Account / runtime profile target-profile ownership is enforced after
// `authSelection` resolves the profile metadata (below). Connection
// ownership for the rebind itself is covered by the top-level
// member-write gate above.

// Resolve the new device-worker binding up front so a bad value rejects the
// whole update.
let nextDeviceWorkerId: string | null = null;
Expand Down Expand Up @@ -1709,6 +1753,29 @@ async function handleUpdate(
};
}

// Non-admins may only bind to a runtime profile they own. Mirrors the
// handleCreate target-profile guard so a member who created a connection
// can't pivot it onto another member's credentials. `env` profiles are
// admin-managed org-shared credentials — same rule as create.
if (hasAuthProfileArg && !callerIsAdmin && authSelection.authProfile) {
const profile = authSelection.authProfile;
if (profile.profile_kind === 'env') {
return {
error:
'Only admins can use env-credential auth profiles. Ask an admin to rebind this connection.',
};
}
if (
(profile.profile_kind === 'oauth_account' ||
profile.profile_kind === 'browser_session') &&
profile.created_by !== ctx.userId
) {
return {
error: `Auth profile '${profile.slug}' belongs to another user. Create your own profile (action: 'create_auth_profile') and use its slug instead.`,
};
}
}

const currentAuthProfile = await getAuthProfileById(organizationId, existing.auth_profile_id);
const currentAppAuthProfile = await getAuthProfileById(
organizationId,
Expand Down
Loading