Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
4 changes: 2 additions & 2 deletions .mise-tasks/lint/migrations.sh
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ squawk_cmd=""
# Check if running in GitHub Actions environment
if [ -n "$usage_github_token" ] && [ -n "$usage_github_event_path" ]; then
echo "Running in GitHub Actions environment"

squawk_cmd="upload-to-github"

SQUAWK_GITHUB_TOKEN=$usage_github_token
Expand All @@ -49,7 +49,7 @@ fi

printf "Changed files:\n%s\n" "${files[@]}"

printf "%s\n" "${files[@]}" | xargs squawk "$squawk_cmd" \
printf "%s\n" "${files[@]}" | xargs squawk -- "$squawk_cmd" \
--config server/.squawk.toml

# We cannot use squawk's `ban-concurrent-index-creation-in-transaction` rule
Expand Down
2 changes: 1 addition & 1 deletion mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
# well as overriding environment variables.
[tools]
"aqua:ariga/atlas" = "0.38.0"
"github:sbdchd/squawk" = "2.36.0"
"github:sbdchd/squawk" = "2.38.0"
"github:speakeasy-api/speakeasy" = "1.700.1"
"github:FiloSottile/mkcert" = "1.4.4"
apko = "1.0.1"
Expand Down
77 changes: 77 additions & 0 deletions server/database/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -1208,3 +1208,80 @@ CREATE TABLE IF NOT EXISTS project_allowed_origins (
CREATE UNIQUE INDEX IF NOT EXISTS project_allowed_origins_project_id_origin_key
ON project_allowed_origins (project_id, origin)
WHERE deleted IS FALSE;

-- User OAuth tokens for external MCP servers
-- Stores tokens obtained from external OAuth 2.1 providers (e.g., Google, Atlassian)
-- when users authenticate to use external MCP servers in the Playground.
-- Scoped per user + organization + OAuth issuer, allowing one token to work
-- for multiple MCP servers that share the same OAuth provider.
CREATE TABLE IF NOT EXISTS user_oauth_tokens (
id uuid NOT NULL DEFAULT generate_uuidv7(),

-- Scoping: per user, per org, per OAuth server (RFC recommendation)
user_id TEXT NOT NULL,
organization_id TEXT NOT NULL,

-- OAuth 2.1 server issuer URL (from AS metadata, e.g., "https://accounts.google.com")
-- This allows token reuse across MCP servers sharing the same OAuth provider
oauth_server_issuer TEXT NOT NULL CHECK (oauth_server_issuer <> '' AND CHAR_LENGTH(oauth_server_issuer) <= 500),

-- Token data (encrypted at rest via application layer)
access_token_encrypted TEXT NOT NULL,
refresh_token_encrypted TEXT, -- Optional, for refresh flow
token_type TEXT NOT NULL DEFAULT 'Bearer',
expires_at timestamptz, -- When access token expires (NULL if non-expiring)
scope TEXT, -- Space-separated granted scopes

-- Metadata for debugging/display
provider_name TEXT, -- Human-readable name (e.g., "Google", "Atlassian")

created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
deleted_at timestamptz,
deleted boolean NOT NULL GENERATED ALWAYS AS (deleted_at IS NOT NULL) stored,

CONSTRAINT user_oauth_tokens_pkey PRIMARY KEY (id),
CONSTRAINT user_oauth_tokens_user_id_fkey FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
CONSTRAINT user_oauth_tokens_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organization_metadata (id) ON DELETE CASCADE
);

-- Unique constraint: one token per user per org per OAuth issuer
CREATE UNIQUE INDEX IF NOT EXISTS user_oauth_tokens_user_org_issuer_key
ON user_oauth_tokens (user_id, organization_id, oauth_server_issuer)
WHERE deleted IS FALSE;

-- Index for looking up tokens by user within an org
CREATE INDEX IF NOT EXISTS user_oauth_tokens_user_org_idx
ON user_oauth_tokens (user_id, organization_id)
WHERE deleted IS FALSE;

-- Organization-level OAuth client registrations from Dynamic Client Registration (DCR)
-- When Gram acts as an OAuth client to external MCP servers using MCP OAuth 2.1,
-- it needs to register itself via DCR and store the resulting client credentials.
-- These credentials are shared by all users in the organization.
CREATE TABLE IF NOT EXISTS external_oauth_client_registrations (
id uuid NOT NULL DEFAULT generate_uuidv7(),
organization_id TEXT NOT NULL,

-- OAuth server issuer URL (from AS metadata or derived from auth endpoint origin)
oauth_server_issuer TEXT NOT NULL CHECK (oauth_server_issuer <> ''),

-- Client credentials from DCR response
client_id TEXT NOT NULL CHECK (client_id <> ''),
client_secret_encrypted TEXT, -- May be null for public clients (PKCE-only)
client_id_issued_at timestamptz,
client_secret_expires_at timestamptz, -- When the secret expires (null = never)

created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
deleted_at timestamptz,
deleted boolean NOT NULL GENERATED ALWAYS AS (deleted_at IS NOT NULL) stored,

CONSTRAINT external_oauth_client_registrations_pkey PRIMARY KEY (id),
CONSTRAINT external_oauth_client_registrations_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organization_metadata(id) ON DELETE CASCADE
);

-- Unique constraint: one client registration per org per OAuth issuer
CREATE UNIQUE INDEX IF NOT EXISTS external_oauth_client_registrations_org_issuer_key
ON external_oauth_client_registrations (organization_id, oauth_server_issuer)
WHERE deleted IS FALSE;
31 changes: 31 additions & 0 deletions server/internal/database/models.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 31 additions & 0 deletions server/internal/oauth/repo/models.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

106 changes: 106 additions & 0 deletions server/internal/oauth/repo/queries.sql
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,109 @@ WHERE project_id = @project_id AND id = @id;
SELECT * FROM oauth_proxy_providers
WHERE oauth_proxy_server_id = @oauth_proxy_server_id AND project_id = @project_id AND deleted IS FALSE
ORDER BY created_at ASC;

-- User OAuth Tokens Queries
-- Stores tokens obtained from external OAuth providers for users authenticating to external MCP servers

-- name: UpsertUserOAuthToken :one
INSERT INTO user_oauth_tokens (
user_id,
organization_id,
oauth_server_issuer,
access_token_encrypted,
refresh_token_encrypted,
token_type,
expires_at,
scope,
provider_name
) VALUES (
@user_id,
@organization_id,
@oauth_server_issuer,
@access_token_encrypted,
@refresh_token_encrypted,
@token_type,
@expires_at,
@scope,
@provider_name
) ON CONFLICT (user_id, organization_id, oauth_server_issuer) WHERE deleted IS FALSE DO UPDATE SET
access_token_encrypted = EXCLUDED.access_token_encrypted,
refresh_token_encrypted = EXCLUDED.refresh_token_encrypted,
token_type = EXCLUDED.token_type,
expires_at = EXCLUDED.expires_at,
scope = EXCLUDED.scope,
provider_name = EXCLUDED.provider_name,
updated_at = clock_timestamp()
RETURNING *;

-- name: GetUserOAuthToken :one
SELECT * FROM user_oauth_tokens
WHERE user_id = @user_id
AND organization_id = @organization_id
AND oauth_server_issuer = @oauth_server_issuer
AND deleted IS FALSE;

-- name: GetUserOAuthTokenByID :one
SELECT * FROM user_oauth_tokens
WHERE id = @id AND deleted IS FALSE;

-- name: ListUserOAuthTokens :many
SELECT * FROM user_oauth_tokens
WHERE user_id = @user_id
AND organization_id = @organization_id
AND deleted IS FALSE
ORDER BY created_at DESC;

-- name: DeleteUserOAuthToken :exec
UPDATE user_oauth_tokens SET
deleted_at = clock_timestamp(),
updated_at = clock_timestamp()
WHERE id = @id;

-- name: DeleteUserOAuthTokenByIssuer :exec
UPDATE user_oauth_tokens SET
deleted_at = clock_timestamp(),
updated_at = clock_timestamp()
WHERE user_id = @user_id
AND organization_id = @organization_id
AND oauth_server_issuer = @oauth_server_issuer;

-- External OAuth Client Registrations Queries
-- Stores client credentials from Dynamic Client Registration (DCR)
-- These are organization-level credentials, not user-level

-- name: UpsertExternalOAuthClientRegistration :one
INSERT INTO external_oauth_client_registrations (
organization_id,
oauth_server_issuer,
client_id,
client_secret_encrypted,
client_id_issued_at,
client_secret_expires_at
) VALUES (
@organization_id,
@oauth_server_issuer,
@client_id,
@client_secret_encrypted,
@client_id_issued_at,
@client_secret_expires_at
) ON CONFLICT (organization_id, oauth_server_issuer) WHERE deleted IS FALSE DO UPDATE SET
client_id = EXCLUDED.client_id,
client_secret_encrypted = EXCLUDED.client_secret_encrypted,
client_id_issued_at = EXCLUDED.client_id_issued_at,
client_secret_expires_at = EXCLUDED.client_secret_expires_at,
updated_at = clock_timestamp()
RETURNING *;

-- name: GetExternalOAuthClientRegistration :one
SELECT * FROM external_oauth_client_registrations
WHERE organization_id = @organization_id
AND oauth_server_issuer = @oauth_server_issuer
AND deleted IS FALSE;

-- name: DeleteExternalOAuthClientRegistration :exec
UPDATE external_oauth_client_registrations SET
deleted_at = clock_timestamp(),
updated_at = clock_timestamp()
WHERE organization_id = @organization_id
AND oauth_server_issuer = @oauth_server_issuer;
Loading
Loading