From de11dc71e26bd0ec0bb232a129ea7c44356e510f Mon Sep 17 00:00:00 2001 From: Walker Lockard Date: Wed, 28 Jan 2026 17:38:27 -0800 Subject: [PATCH 1/5] db migration for user oauth tokens --- server/database/schema.sql | 77 ++++ server/internal/database/models.go | 31 ++ server/internal/oauth/repo/models.go | 31 ++ server/internal/oauth/repo/queries.sql | 106 ++++++ server/internal/oauth/repo/queries.sql.go | 334 ++++++++++++++++++ server/internal/toolsets/queries.sql | 5 + server/internal/toolsets/repo/queries.sql.go | 32 ++ ...0260129012858_store-user-access-tokens.sql | 45 +++ server/migrations/atlas.sum | 3 +- 9 files changed, 663 insertions(+), 1 deletion(-) create mode 100644 server/migrations/20260129012858_store-user-access-tokens.sql diff --git a/server/database/schema.sql b/server/database/schema.sql index 846f389a7..805dcda79 100644 --- a/server/database/schema.sql +++ b/server/database/schema.sql @@ -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; diff --git a/server/internal/database/models.go b/server/internal/database/models.go index 5e1286199..6b4af29f0 100644 --- a/server/internal/database/models.go +++ b/server/internal/database/models.go @@ -234,6 +234,20 @@ type ExternalMcpToolDefinition struct { Deleted bool } +type ExternalOauthClientRegistration struct { + ID uuid.UUID + OrganizationID string + OauthServerIssuer string + ClientID string + ClientSecretEncrypted pgtype.Text + ClientIDIssuedAt pgtype.Timestamptz + ClientSecretExpiresAt pgtype.Timestamptz + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + DeletedAt pgtype.Timestamptz + Deleted bool +} + type ExternalOauthServerMetadatum struct { ID uuid.UUID ProjectID uuid.UUID @@ -712,3 +726,20 @@ type User struct { CreatedAt pgtype.Timestamptz UpdatedAt pgtype.Timestamptz } + +type UserOauthToken struct { + ID uuid.UUID + UserID string + OrganizationID string + OauthServerIssuer string + AccessTokenEncrypted string + RefreshTokenEncrypted pgtype.Text + TokenType string + ExpiresAt pgtype.Timestamptz + Scope pgtype.Text + ProviderName pgtype.Text + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + DeletedAt pgtype.Timestamptz + Deleted bool +} diff --git a/server/internal/oauth/repo/models.go b/server/internal/oauth/repo/models.go index c9d8bcee7..afd824413 100644 --- a/server/internal/oauth/repo/models.go +++ b/server/internal/oauth/repo/models.go @@ -9,6 +9,20 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type ExternalOauthClientRegistration struct { + ID uuid.UUID + OrganizationID string + OauthServerIssuer string + ClientID string + ClientSecretEncrypted pgtype.Text + ClientIDIssuedAt pgtype.Timestamptz + ClientSecretExpiresAt pgtype.Timestamptz + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + DeletedAt pgtype.Timestamptz + Deleted bool +} + type ExternalOauthServerMetadatum struct { ID uuid.UUID ProjectID uuid.UUID @@ -51,3 +65,20 @@ type OauthProxyServer struct { DeletedAt pgtype.Timestamptz Deleted bool } + +type UserOauthToken struct { + ID uuid.UUID + UserID string + OrganizationID string + OauthServerIssuer string + AccessTokenEncrypted string + RefreshTokenEncrypted pgtype.Text + TokenType string + ExpiresAt pgtype.Timestamptz + Scope pgtype.Text + ProviderName pgtype.Text + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + DeletedAt pgtype.Timestamptz + Deleted bool +} diff --git a/server/internal/oauth/repo/queries.sql b/server/internal/oauth/repo/queries.sql index 5e497aed7..83ea38f7e 100644 --- a/server/internal/oauth/repo/queries.sql +++ b/server/internal/oauth/repo/queries.sql @@ -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; diff --git a/server/internal/oauth/repo/queries.sql.go b/server/internal/oauth/repo/queries.sql.go index 7cc2e7d57..70fa43cb4 100644 --- a/server/internal/oauth/repo/queries.sql.go +++ b/server/internal/oauth/repo/queries.sql.go @@ -48,6 +48,24 @@ func (q *Queries) CreateExternalOAuthServerMetadata(ctx context.Context, arg Cre return i, err } +const deleteExternalOAuthClientRegistration = `-- name: DeleteExternalOAuthClientRegistration :exec +UPDATE external_oauth_client_registrations SET + deleted_at = clock_timestamp(), + updated_at = clock_timestamp() +WHERE organization_id = $1 + AND oauth_server_issuer = $2 +` + +type DeleteExternalOAuthClientRegistrationParams struct { + OrganizationID string + OauthServerIssuer string +} + +func (q *Queries) DeleteExternalOAuthClientRegistration(ctx context.Context, arg DeleteExternalOAuthClientRegistrationParams) error { + _, err := q.db.Exec(ctx, deleteExternalOAuthClientRegistration, arg.OrganizationID, arg.OauthServerIssuer) + return err +} + const deleteExternalOAuthServerMetadata = `-- name: DeleteExternalOAuthServerMetadata :exec UPDATE external_oauth_server_metadata SET deleted_at = clock_timestamp(), @@ -99,6 +117,69 @@ func (q *Queries) DeleteOAuthProxyServer(ctx context.Context, arg DeleteOAuthPro return err } +const deleteUserOAuthToken = `-- name: DeleteUserOAuthToken :exec +UPDATE user_oauth_tokens SET + deleted_at = clock_timestamp(), + updated_at = clock_timestamp() +WHERE id = $1 +` + +func (q *Queries) DeleteUserOAuthToken(ctx context.Context, id uuid.UUID) error { + _, err := q.db.Exec(ctx, deleteUserOAuthToken, id) + return err +} + +const deleteUserOAuthTokenByIssuer = `-- name: DeleteUserOAuthTokenByIssuer :exec +UPDATE user_oauth_tokens SET + deleted_at = clock_timestamp(), + updated_at = clock_timestamp() +WHERE user_id = $1 + AND organization_id = $2 + AND oauth_server_issuer = $3 +` + +type DeleteUserOAuthTokenByIssuerParams struct { + UserID string + OrganizationID string + OauthServerIssuer string +} + +func (q *Queries) DeleteUserOAuthTokenByIssuer(ctx context.Context, arg DeleteUserOAuthTokenByIssuerParams) error { + _, err := q.db.Exec(ctx, deleteUserOAuthTokenByIssuer, arg.UserID, arg.OrganizationID, arg.OauthServerIssuer) + return err +} + +const getExternalOAuthClientRegistration = `-- name: GetExternalOAuthClientRegistration :one +SELECT id, organization_id, oauth_server_issuer, client_id, client_secret_encrypted, client_id_issued_at, client_secret_expires_at, created_at, updated_at, deleted_at, deleted FROM external_oauth_client_registrations +WHERE organization_id = $1 + AND oauth_server_issuer = $2 + AND deleted IS FALSE +` + +type GetExternalOAuthClientRegistrationParams struct { + OrganizationID string + OauthServerIssuer string +} + +func (q *Queries) GetExternalOAuthClientRegistration(ctx context.Context, arg GetExternalOAuthClientRegistrationParams) (ExternalOauthClientRegistration, error) { + row := q.db.QueryRow(ctx, getExternalOAuthClientRegistration, arg.OrganizationID, arg.OauthServerIssuer) + var i ExternalOauthClientRegistration + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.OauthServerIssuer, + &i.ClientID, + &i.ClientSecretEncrypted, + &i.ClientIDIssuedAt, + &i.ClientSecretExpiresAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + &i.Deleted, + ) + return i, err +} + const getExternalOAuthServerMetadata = `-- name: GetExternalOAuthServerMetadata :one SELECT id, project_id, slug, metadata, created_at, updated_at, deleted_at, deleted FROM external_oauth_server_metadata WHERE project_id = $1 AND id = $2 AND deleted IS FALSE @@ -151,6 +232,69 @@ func (q *Queries) GetOAuthProxyServer(ctx context.Context, arg GetOAuthProxyServ return i, err } +const getUserOAuthToken = `-- name: GetUserOAuthToken :one +SELECT id, user_id, organization_id, oauth_server_issuer, access_token_encrypted, refresh_token_encrypted, token_type, expires_at, scope, provider_name, created_at, updated_at, deleted_at, deleted FROM user_oauth_tokens +WHERE user_id = $1 + AND organization_id = $2 + AND oauth_server_issuer = $3 + AND deleted IS FALSE +` + +type GetUserOAuthTokenParams struct { + UserID string + OrganizationID string + OauthServerIssuer string +} + +func (q *Queries) GetUserOAuthToken(ctx context.Context, arg GetUserOAuthTokenParams) (UserOauthToken, error) { + row := q.db.QueryRow(ctx, getUserOAuthToken, arg.UserID, arg.OrganizationID, arg.OauthServerIssuer) + var i UserOauthToken + err := row.Scan( + &i.ID, + &i.UserID, + &i.OrganizationID, + &i.OauthServerIssuer, + &i.AccessTokenEncrypted, + &i.RefreshTokenEncrypted, + &i.TokenType, + &i.ExpiresAt, + &i.Scope, + &i.ProviderName, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + &i.Deleted, + ) + return i, err +} + +const getUserOAuthTokenByID = `-- name: GetUserOAuthTokenByID :one +SELECT id, user_id, organization_id, oauth_server_issuer, access_token_encrypted, refresh_token_encrypted, token_type, expires_at, scope, provider_name, created_at, updated_at, deleted_at, deleted FROM user_oauth_tokens +WHERE id = $1 AND deleted IS FALSE +` + +func (q *Queries) GetUserOAuthTokenByID(ctx context.Context, id uuid.UUID) (UserOauthToken, error) { + row := q.db.QueryRow(ctx, getUserOAuthTokenByID, id) + var i UserOauthToken + err := row.Scan( + &i.ID, + &i.UserID, + &i.OrganizationID, + &i.OauthServerIssuer, + &i.AccessTokenEncrypted, + &i.RefreshTokenEncrypted, + &i.TokenType, + &i.ExpiresAt, + &i.Scope, + &i.ProviderName, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + &i.Deleted, + ) + return i, err +} + const listOAuthProxyProvidersByServer = `-- name: ListOAuthProxyProvidersByServer :many SELECT id, project_id, oauth_proxy_server_id, slug, provider_type, authorization_endpoint, token_endpoint, registration_endpoint, scopes_supported, response_types_supported, response_modes_supported, grant_types_supported, token_endpoint_auth_methods_supported, security_key_names, secrets, created_at, updated_at, deleted_at, deleted FROM oauth_proxy_providers WHERE oauth_proxy_server_id = $1 AND project_id = $2 AND deleted IS FALSE @@ -202,6 +346,117 @@ func (q *Queries) ListOAuthProxyProvidersByServer(ctx context.Context, arg ListO return items, nil } +const listUserOAuthTokens = `-- name: ListUserOAuthTokens :many +SELECT id, user_id, organization_id, oauth_server_issuer, access_token_encrypted, refresh_token_encrypted, token_type, expires_at, scope, provider_name, created_at, updated_at, deleted_at, deleted FROM user_oauth_tokens +WHERE user_id = $1 + AND organization_id = $2 + AND deleted IS FALSE +ORDER BY created_at DESC +` + +type ListUserOAuthTokensParams struct { + UserID string + OrganizationID string +} + +func (q *Queries) ListUserOAuthTokens(ctx context.Context, arg ListUserOAuthTokensParams) ([]UserOauthToken, error) { + rows, err := q.db.Query(ctx, listUserOAuthTokens, arg.UserID, arg.OrganizationID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []UserOauthToken + for rows.Next() { + var i UserOauthToken + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.OrganizationID, + &i.OauthServerIssuer, + &i.AccessTokenEncrypted, + &i.RefreshTokenEncrypted, + &i.TokenType, + &i.ExpiresAt, + &i.Scope, + &i.ProviderName, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + &i.Deleted, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertExternalOAuthClientRegistration = `-- 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 ( + $1, + $2, + $3, + $4, + $5, + $6 +) 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 id, organization_id, oauth_server_issuer, client_id, client_secret_encrypted, client_id_issued_at, client_secret_expires_at, created_at, updated_at, deleted_at, deleted +` + +type UpsertExternalOAuthClientRegistrationParams struct { + OrganizationID string + OauthServerIssuer string + ClientID string + ClientSecretEncrypted pgtype.Text + ClientIDIssuedAt pgtype.Timestamptz + ClientSecretExpiresAt pgtype.Timestamptz +} + +// External OAuth Client Registrations Queries +// Stores client credentials from Dynamic Client Registration (DCR) +// These are organization-level credentials, not user-level +func (q *Queries) UpsertExternalOAuthClientRegistration(ctx context.Context, arg UpsertExternalOAuthClientRegistrationParams) (ExternalOauthClientRegistration, error) { + row := q.db.QueryRow(ctx, upsertExternalOAuthClientRegistration, + arg.OrganizationID, + arg.OauthServerIssuer, + arg.ClientID, + arg.ClientSecretEncrypted, + arg.ClientIDIssuedAt, + arg.ClientSecretExpiresAt, + ) + var i ExternalOauthClientRegistration + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.OauthServerIssuer, + &i.ClientID, + &i.ClientSecretEncrypted, + &i.ClientIDIssuedAt, + &i.ClientSecretExpiresAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + &i.Deleted, + ) + return i, err +} + const upsertOAuthProxyProvider = `-- name: UpsertOAuthProxyProvider :one INSERT INTO oauth_proxy_providers ( @@ -344,3 +599,82 @@ func (q *Queries) UpsertOAuthProxyServer(ctx context.Context, arg UpsertOAuthPro ) return i, err } + +const upsertUserOAuthToken = `-- 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 ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9 +) 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 id, user_id, organization_id, oauth_server_issuer, access_token_encrypted, refresh_token_encrypted, token_type, expires_at, scope, provider_name, created_at, updated_at, deleted_at, deleted +` + +type UpsertUserOAuthTokenParams struct { + UserID string + OrganizationID string + OauthServerIssuer string + AccessTokenEncrypted string + RefreshTokenEncrypted pgtype.Text + TokenType string + ExpiresAt pgtype.Timestamptz + Scope pgtype.Text + ProviderName pgtype.Text +} + +// User OAuth Tokens Queries +// Stores tokens obtained from external OAuth providers for users authenticating to external MCP servers +func (q *Queries) UpsertUserOAuthToken(ctx context.Context, arg UpsertUserOAuthTokenParams) (UserOauthToken, error) { + row := q.db.QueryRow(ctx, upsertUserOAuthToken, + arg.UserID, + arg.OrganizationID, + arg.OauthServerIssuer, + arg.AccessTokenEncrypted, + arg.RefreshTokenEncrypted, + arg.TokenType, + arg.ExpiresAt, + arg.Scope, + arg.ProviderName, + ) + var i UserOauthToken + err := row.Scan( + &i.ID, + &i.UserID, + &i.OrganizationID, + &i.OauthServerIssuer, + &i.AccessTokenEncrypted, + &i.RefreshTokenEncrypted, + &i.TokenType, + &i.ExpiresAt, + &i.Scope, + &i.ProviderName, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + &i.Deleted, + ) + return i, err +} diff --git a/server/internal/toolsets/queries.sql b/server/internal/toolsets/queries.sql index ca93c8640..cf03e2f1a 100644 --- a/server/internal/toolsets/queries.sql +++ b/server/internal/toolsets/queries.sql @@ -3,6 +3,11 @@ SELECT * FROM toolsets WHERE slug = @slug AND project_id = @project_id AND deleted IS FALSE; +-- name: GetToolsetByID :one +SELECT * +FROM toolsets +WHERE id = @id AND deleted IS FALSE; + -- name: GetToolsetByMCPSlug :one -- project_id is required to ensure uniqueness since mcp_slug is only unique within a project SELECT * diff --git a/server/internal/toolsets/repo/queries.sql.go b/server/internal/toolsets/repo/queries.sql.go index cd9533130..9106bc7fb 100644 --- a/server/internal/toolsets/repo/queries.sql.go +++ b/server/internal/toolsets/repo/queries.sql.go @@ -465,6 +465,38 @@ func (q *Queries) GetToolset(ctx context.Context, arg GetToolsetParams) (Toolset return i, err } +const getToolsetByID = `-- name: GetToolsetByID :one +SELECT id, organization_id, project_id, name, slug, description, default_environment_slug, mcp_slug, mcp_is_public, mcp_enabled, tool_selection_mode, custom_domain_id, external_oauth_server_id, oauth_proxy_server_id, created_at, updated_at, deleted_at, deleted +FROM toolsets +WHERE id = $1 AND deleted IS FALSE +` + +func (q *Queries) GetToolsetByID(ctx context.Context, id uuid.UUID) (Toolset, error) { + row := q.db.QueryRow(ctx, getToolsetByID, id) + var i Toolset + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.ProjectID, + &i.Name, + &i.Slug, + &i.Description, + &i.DefaultEnvironmentSlug, + &i.McpSlug, + &i.McpIsPublic, + &i.McpEnabled, + &i.ToolSelectionMode, + &i.CustomDomainID, + &i.ExternalOauthServerID, + &i.OauthProxyServerID, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + &i.Deleted, + ) + return i, err +} + const getToolsetByMCPSlug = `-- name: GetToolsetByMCPSlug :one SELECT id, organization_id, project_id, name, slug, description, default_environment_slug, mcp_slug, mcp_is_public, mcp_enabled, tool_selection_mode, custom_domain_id, external_oauth_server_id, oauth_proxy_server_id, created_at, updated_at, deleted_at, deleted FROM toolsets diff --git a/server/migrations/20260129012858_store-user-access-tokens.sql b/server/migrations/20260129012858_store-user-access-tokens.sql new file mode 100644 index 000000000..b74d999ff --- /dev/null +++ b/server/migrations/20260129012858_store-user-access-tokens.sql @@ -0,0 +1,45 @@ +-- Create "external_oauth_client_registrations" table +CREATE TABLE "external_oauth_client_registrations" ( + "id" uuid NOT NULL DEFAULT generate_uuidv7(), + "organization_id" text NOT NULL, + "oauth_server_issuer" text NOT NULL, + "client_id" text NOT NULL, + "client_secret_encrypted" text NULL, + "client_id_issued_at" timestamptz NULL, + "client_secret_expires_at" timestamptz NULL, + "created_at" timestamptz NOT NULL DEFAULT clock_timestamp(), + "updated_at" timestamptz NOT NULL DEFAULT clock_timestamp(), + "deleted_at" timestamptz NULL, + "deleted" boolean NOT NULL GENERATED ALWAYS AS (deleted_at IS NOT NULL) STORED, + PRIMARY KEY ("id"), + CONSTRAINT "external_oauth_client_registrations_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organization_metadata" ("id") ON UPDATE NO ACTION ON DELETE CASCADE, + CONSTRAINT "external_oauth_client_registrations_client_id_check" CHECK (client_id <> ''::text), + CONSTRAINT "external_oauth_client_registrations_oauth_server_issuer_check" CHECK (oauth_server_issuer <> ''::text) +); +-- Create index "external_oauth_client_registrations_org_issuer_key" to table: "external_oauth_client_registrations" +CREATE UNIQUE INDEX "external_oauth_client_registrations_org_issuer_key" ON "external_oauth_client_registrations" ("organization_id", "oauth_server_issuer") WHERE (deleted IS FALSE); +-- Create "user_oauth_tokens" table +CREATE TABLE "user_oauth_tokens" ( + "id" uuid NOT NULL DEFAULT generate_uuidv7(), + "user_id" text NOT NULL, + "organization_id" text NOT NULL, + "oauth_server_issuer" text NOT NULL, + "access_token_encrypted" text NOT NULL, + "refresh_token_encrypted" text NULL, + "token_type" text NOT NULL DEFAULT 'Bearer', + "expires_at" timestamptz NULL, + "scope" text NULL, + "provider_name" text NULL, + "created_at" timestamptz NOT NULL DEFAULT clock_timestamp(), + "updated_at" timestamptz NOT NULL DEFAULT clock_timestamp(), + "deleted_at" timestamptz NULL, + "deleted" boolean NOT NULL GENERATED ALWAYS AS (deleted_at IS NOT NULL) STORED, + PRIMARY KEY ("id"), + CONSTRAINT "user_oauth_tokens_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organization_metadata" ("id") ON UPDATE NO ACTION ON DELETE CASCADE, + CONSTRAINT "user_oauth_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON UPDATE NO ACTION ON DELETE CASCADE, + CONSTRAINT "user_oauth_tokens_oauth_server_issuer_check" CHECK ((oauth_server_issuer <> ''::text) AND (char_length(oauth_server_issuer) <= 500)) +); +-- Create index "user_oauth_tokens_user_org_idx" to table: "user_oauth_tokens" +CREATE INDEX "user_oauth_tokens_user_org_idx" ON "user_oauth_tokens" ("user_id", "organization_id") WHERE (deleted IS FALSE); +-- Create index "user_oauth_tokens_user_org_issuer_key" to table: "user_oauth_tokens" +CREATE UNIQUE INDEX "user_oauth_tokens_user_org_issuer_key" ON "user_oauth_tokens" ("user_id", "organization_id", "oauth_server_issuer") WHERE (deleted IS FALSE); diff --git a/server/migrations/atlas.sum b/server/migrations/atlas.sum index 882d43de0..37da99563 100644 --- a/server/migrations/atlas.sum +++ b/server/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:Ah6FwNY+7w3q4GD+RVg4p37x0+s4EanTIw7Z1FM6Z90= +h1:D3qRAF+zYWaSkGD1I6dVRRng3iN0SMgoKODTevhCx9U= 20250502122425_initial-tables.sql h1:Hu3O60/bB4fjZpUay8FzyOjw6vngp087zU+U/wVKn7k= 20250502130852_initial-indexes.sql h1:oYbnwi9y9PPTqu7uVbSPSALhCY8XF3rv03nDfG4b7mo= 20250502154250_relax-http-security-fields.sql h1:0+OYIDq7IHmx7CP5BChVwfpF2rOSrRDxnqawXio2EVo= @@ -92,3 +92,4 @@ h1:Ah6FwNY+7w3q4GD+RVg4p37x0+s4EanTIw7Z1FM6Z90= 20260122192347_chat-content-raw-columns.sql h1:Y1XDsAX1O+f1hsJWFf3IG6pC33h9mtqWidy7RAm/4NI= 20260127002212_mcp_env_vars.sql h1:SyyU7ZXDITVxMGsSPCAjuBBNXNgxfm+3DjmV5YDV/FQ= 20260127183030_add_header_definitions_for_external_mcp.sql h1:zhgPD7ecRzM2VFj67jchsx0TiBLpLD4QoIvgT+7TKSw= +20260129012858_store-user-access-tokens.sql h1:T+2YkVnwofu5J17X6RlmN6sC7z2w692mA+XVjmKC4EU= From bc6b038811e64eae04c1ff707bd46d7f385dec4b Mon Sep 17 00:00:00 2001 From: Walker Lockard Date: Wed, 28 Jan 2026 17:54:59 -0800 Subject: [PATCH 2/5] update lint:migrations script --- .mise-tasks/lint/migrations.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.mise-tasks/lint/migrations.sh b/.mise-tasks/lint/migrations.sh index 5f118113a..45e090d4d 100755 --- a/.mise-tasks/lint/migrations.sh +++ b/.mise-tasks/lint/migrations.sh @@ -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 @@ -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 mise exec squawk -- "$squawk_cmd" \ --config server/.squawk.toml # We cannot use squawk's `ban-concurrent-index-creation-in-transaction` rule From 93826e53359b8587373126e11e0cf97930870c34 Mon Sep 17 00:00:00 2001 From: Walker Lockard Date: Wed, 28 Jan 2026 18:10:53 -0800 Subject: [PATCH 3/5] squawk fix --- .mise-tasks/lint/migrations.sh | 2 +- mise.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.mise-tasks/lint/migrations.sh b/.mise-tasks/lint/migrations.sh index 45e090d4d..65fce81d8 100755 --- a/.mise-tasks/lint/migrations.sh +++ b/.mise-tasks/lint/migrations.sh @@ -49,7 +49,7 @@ fi printf "Changed files:\n%s\n" "${files[@]}" -printf "%s\n" "${files[@]}" | xargs mise exec 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 diff --git a/mise.toml b/mise.toml index 44a71a001..e12217564 100644 --- a/mise.toml +++ b/mise.toml @@ -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" From 9ede5262ea2aba73de8d2886cdd37d54e787f39b Mon Sep 17 00:00:00 2001 From: Walker Lockard Date: Thu, 29 Jan 2026 07:33:44 -0800 Subject: [PATCH 4/5] correcting syntax error in migration.sh --- .mise-tasks/lint/migrations.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.mise-tasks/lint/migrations.sh b/.mise-tasks/lint/migrations.sh index 65fce81d8..17e9e6a9c 100755 --- a/.mise-tasks/lint/migrations.sh +++ b/.mise-tasks/lint/migrations.sh @@ -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 From 125b257966ad9b0243d16d8a0af296ea46600729 Mon Sep 17 00:00:00 2001 From: Walker Lockard Date: Thu, 29 Jan 2026 09:08:15 -0800 Subject: [PATCH 5/5] rehash --- server/migrations/atlas.sum | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/migrations/atlas.sum b/server/migrations/atlas.sum index 67e9d4715..129ac78a9 100644 --- a/server/migrations/atlas.sum +++ b/server/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:a5fTjDzKO1C3aQTFP3G/lmNMJ50ODuiPyJ+jpk4m3yY= +h1:lllbolIP1v2oX0xs7947Qly+VC6xOlZKCpqB/wvsfUs= 20250502122425_initial-tables.sql h1:Hu3O60/bB4fjZpUay8FzyOjw6vngp087zU+U/wVKn7k= 20250502130852_initial-indexes.sql h1:oYbnwi9y9PPTqu7uVbSPSALhCY8XF3rv03nDfG4b7mo= 20250502154250_relax-http-security-fields.sql h1:0+OYIDq7IHmx7CP5BChVwfpF2rOSrRDxnqawXio2EVo= @@ -92,4 +92,5 @@ h1:a5fTjDzKO1C3aQTFP3G/lmNMJ50ODuiPyJ+jpk4m3yY= 20260122192347_chat-content-raw-columns.sql h1:Y1XDsAX1O+f1hsJWFf3IG6pC33h9mtqWidy7RAm/4NI= 20260127002212_mcp_env_vars.sql h1:SyyU7ZXDITVxMGsSPCAjuBBNXNgxfm+3DjmV5YDV/FQ= 20260127183030_add_header_definitions_for_external_mcp.sql h1:zhgPD7ecRzM2VFj67jchsx0TiBLpLD4QoIvgT+7TKSw= -20260129164323_install_redirect.sql h1:QK9StZ1pz4N+oRPcNYFeOx8AVt2tQBRJC+NRKJSeWOk= +20260129012858_store-user-access-tokens.sql h1:T+2YkVnwofu5J17X6RlmN6sC7z2w692mA+XVjmKC4EU= +20260129164323_install_redirect.sql h1:9fUFJxoiN/r/x/2YBm5bwuIQSYRmN8+osposMcu1SxU=