Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1e4e46a
feat: add GitLab repository workspace support
raphaeltm Jul 8, 2026
3a24e33
test: record docker vm server validation
raphaeltm Jul 8, 2026
e06e820
Merge remote-tracking branch 'origin/sam/gitlab-platform-config-wip' …
raphaeltm Jul 11, 2026
ebba69a
fix: refresh GitLab workspace integration
raphaeltm Jul 11, 2026
81f56ba
test: harden GitLab repository boundaries
raphaeltm Jul 11, 2026
c26162c
review: address GitLab workspace Phase 5 specialist findings
raphaeltm Jul 11, 2026
d159f0d
Merge branch 'sam/gitlab-platform-config-wip' into sam/gitlab-repo-wo…
raphaeltm Jul 12, 2026
41e792c
fix: renumber GitLab sidecar migration to 0092 after main added 0091_…
raphaeltm Jul 12, 2026
05e537b
fix: fail-closed GitLab git-credential vending in vm-agent
raphaeltm Jul 12, 2026
1d16759
feat: serialize GitLab OAuth refresh via per-user DO lock
raphaeltm Jul 12, 2026
098dcf2
fix: harden vm-agent GitLab credential path and MR creation
raphaeltm Jul 12, 2026
7cbddd5
fix: bind GitLab git-token vending to workspace owner and thread toke…
raphaeltm Jul 12, 2026
83a88a6
test: close GitLab config and git-token vending coverage gaps
raphaeltm Jul 12, 2026
8b46505
docs: record security review decisions for GitLab token vending
raphaeltm Jul 12, 2026
80a485e
test: update project-delete counts for projectGitlabRepositories cleanup
raphaeltm Jul 12, 2026
e8d43d0
refactor: extract shared token-lock base and gitlab MR test helper
raphaeltm Jul 13, 2026
2c6d3f8
fix: support gitlab credentials in standalone helper
raphaeltm Jul 13, 2026
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
1 change: 1 addition & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ BASE_DOMAIN=workspaces.example.com
# GITLAB_HOST=https://gitlab.com
# GITLAB_CLIENT_ID=
# GITLAB_CLIENT_SECRET=
# GITLAB_API_TIMEOUT_MS=30000 # Timeout for GitLab API calls (default: 30000)

# Cloudflare credentials (for DNS, Origin CA certificate issuance, and optional managed registry cache)
# Requires Account → SSL and Certificates → Edit for per-node Origin CA certificates.
Expand Down
26 changes: 21 additions & 5 deletions apps/api/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import type { Env } from './env';
import { createModuleLogger } from './lib/logger';
import { readResponseJson } from './lib/runtime-validation';
import { getBetterAuthSecret } from './lib/secrets';
import { getGitHubOAuthConfig, getGitLabOAuthConfig, getGoogleLoginOAuthConfig } from './services/platform-config';
import {
getGitHubOAuthConfig,
getGitLabOAuthConfig,
getGoogleLoginOAuthConfig,
} from './services/platform-config';
import { isSignupApprovalRequired } from './services/signup-approval';

const log = createModuleLogger('auth');
Expand Down Expand Up @@ -176,7 +180,9 @@ export function selectPrimaryGitHubEmail(
primary: Boolean(entry.primary),
verified: Boolean(entry.verified),
}))
.filter((entry): entry is { email: string; primary: boolean; verified: boolean } => Boolean(entry.email));
.filter((entry): entry is { email: string; primary: boolean; verified: boolean } =>
Boolean(entry.email)
);

const verifiedPrimary = normalizedEmails.find((entry) => entry.primary && entry.verified);
if (verifiedPrimary) {
Expand Down Expand Up @@ -239,7 +245,11 @@ export async function createAuth(env: Env) {
headers: githubApiHeaders(accessToken),
});
if (emailsRes.ok) {
const emailsData = await readResponseJson(emailsRes, v.array(githubEmailSchema), 'github.user_emails');
const emailsData = await readResponseJson(
emailsRes,
v.array(githubEmailSchema),
'github.user_emails'
);
email = selectPrimaryGitHubEmail(email, emailsData);
} else {
const errorBody = await emailsRes.text();
Expand All @@ -250,11 +260,16 @@ export async function createAuth(env: Env) {
responseBody: errorBody,
});
} else {
log.error('github_emails_fetch_failed', { status: emailsRes.status, responseBody: errorBody });
log.error('github_emails_fetch_failed', {
status: emailsRes.status,
responseBody: errorBody,
});
}
}
} catch (err) {
log.error('github_emails_fetch_exception', { error: err instanceof Error ? err.message : String(err) });
log.error('github_emails_fetch_exception', {
error: err instanceof Error ? err.message : String(err),
});
}

// Last resort: use GitHub noreply email
Expand Down Expand Up @@ -299,6 +314,7 @@ export async function createAuth(env: Env) {
clientId: gitlabOAuth.clientId,
clientSecret: gitlabOAuth.clientSecret,
issuer: gitlabOAuth.host,
scope: ['read_user', 'api', 'read_repository', 'write_repository'],
overrideUserInfoOnSignIn: true,
};
}
Expand Down
27 changes: 27 additions & 0 deletions apps/api/src/db/migrations/0092_project_gitlab_repositories.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- Per-project GitLab repository metadata.
--
-- GitLab uses user OAuth tokens rather than GitHub App installation tokens, so
-- project metadata is stored in a provider sidecar table while projects keeps
-- its historical non-null installation_id sentinel for non-GitHub providers.
CREATE TABLE IF NOT EXISTS project_gitlab_repositories (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
host TEXT NOT NULL,
gitlab_project_id INTEGER NOT NULL,
path_with_namespace TEXT NOT NULL,
web_url TEXT,
http_url_to_repo TEXT NOT NULL,
default_branch TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE UNIQUE INDEX IF NOT EXISTS idx_project_gitlab_repos_project
ON project_gitlab_repositories(project_id);

CREATE UNIQUE INDEX IF NOT EXISTS idx_project_gitlab_repos_user_host_project
ON project_gitlab_repositories(user_id, host, gitlab_project_id);

CREATE INDEX IF NOT EXISTS idx_project_gitlab_repos_project_user
ON project_gitlab_repositories(project_id, user_id);
37 changes: 37 additions & 0 deletions apps/api/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,43 @@ export const projectGithubRepositories = sqliteTable(
})
);

export const projectGitlabRepositories = sqliteTable(
'project_gitlab_repositories',
{
id: text('id').primaryKey(),
projectId: text('project_id')
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
host: text('host').notNull(),
gitlabProjectId: integer('gitlab_project_id').notNull(),
pathWithNamespace: text('path_with_namespace').notNull(),
webUrl: text('web_url'),
httpUrlToRepo: text('http_url_to_repo').notNull(),
defaultBranch: text('default_branch').notNull(),
createdAt: text('created_at')
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at')
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => ({
projectUnique: uniqueIndex('idx_project_gitlab_repos_project').on(table.projectId),
userHostProjectUnique: uniqueIndex('idx_project_gitlab_repos_user_host_project').on(
table.userId,
table.host,
table.gitlabProjectId
),
projectUserIdx: index('idx_project_gitlab_repos_project_user').on(
table.projectId,
table.userId
),
})
);

// =============================================================================
// Project Deployment Credentials (GCP OIDC for Defang deployments)
// Note: This table stores GCP WIF configuration (project IDs, service account
Expand Down
81 changes: 6 additions & 75 deletions apps/api/src/durable-objects/github-user-access-token-lock.ts
Original file line number Diff line number Diff line change
@@ -1,81 +1,12 @@
import { DurableObject } from 'cloudflare:workers';
import * as v from 'valibot';

import { createAuth } from '../auth';
import type { Env } from '../env';
import { log } from '../lib/logger';
import { readResponseJson } from '../lib/runtime-validation';

const requestSchema = v.object({
userId: v.string(),
flow: v.string(),
headers: v.array(v.tuple([v.string(), v.string()])),
});
import { UserAccessTokenLock } from './user-access-token-lock';

/**
* Per-user mutex around BetterAuth GitHub access-token lookup/refresh.
*
* BetterAuth performs the account read, upstream refresh, and account update
* inside `getAccessToken`. GitHub refresh tokens are single-use, so SAM must
* serialize that whole call per user. The read happens inside this DO lock
* because reading before acquiring the lock would let overlapping callers race
* with the same stale refresh token.
* GitHub refresh tokens are single-use, so SAM must serialize the whole
* read → refresh → persist cycle per user. See `UserAccessTokenLock` for the
* shared lock implementation.
*/
export class GitHubUserAccessTokenLock extends DurableObject<Env> {
private refreshLock: Promise<unknown> = Promise.resolve();

private withRefreshLock<T>(fn: () => Promise<T>): Promise<T> {
const run = this.refreshLock.then(() => fn());
this.refreshLock = run.then(
() => undefined,
() => undefined
);
return run;
}

async fetch(request: Request): Promise<Response> {
if (request.method !== 'POST') {
return Response.json({ error: 'method_not_allowed' }, { status: 405 });
}

let payload: v.InferOutput<typeof requestSchema>;
try {
payload = await readResponseJson(
new Response(await request.text(), {
headers: { 'Content-Type': request.headers.get('Content-Type') ?? 'application/json' },
}),
requestSchema,
'github.user_access_token_lock.request'
);
} catch {
return Response.json({ error: 'invalid_request' }, { status: 400 });
}

return this.withRefreshLock(() => this.getAccessToken(payload));
}

private async getAccessToken(payload: v.InferOutput<typeof requestSchema>): Promise<Response> {
try {
const auth = await createAuth(this.env);
const token = await auth.api.getAccessToken({
headers: new Headers(payload.headers),
body: { providerId: 'github', userId: payload.userId },
});

return Response.json({
accessToken: token.accessToken ?? null,
accessTokenExpiresAt: token.accessTokenExpiresAt
? new Date(token.accessTokenExpiresAt).toISOString()
: null,
scopes: token.scopes ?? [],
});
} catch (err) {
log.warn('github.user_access_token_lock.unavailable', {
flow: payload.flow,
userId: payload.userId,
error: err instanceof Error ? err.message : String(err),
});
return Response.json({ error: 'token_unavailable' }, { status: 401 });
}
}
export class GitHubUserAccessTokenLock extends UserAccessTokenLock {
protected readonly providerId = 'github';
}
18 changes: 18 additions & 0 deletions apps/api/src/durable-objects/gitlab-user-access-token-lock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { UserAccessTokenLock } from './user-access-token-lock';

/**
* Per-user mutex around BetterAuth GitLab access-token lookup/refresh.
*
* GitLab refresh tokens are single-use and rotating, so a concurrent replay of
* a consumed refresh token revokes the entire token family upstream. The
* shared lock in `UserAccessTokenLock` serializes the whole
* read → refresh → persist cycle per user.
*
* Refresh-aware rate limiting is tracked in
* tasks/backlog/2026-07-12-gitlab-token-lock-rate-limit.md — most calls are
* cached reads for git credential fetches, so a naive per-call limit would
* throttle legitimate git operations.
*/
export class GitLabUserAccessTokenLock extends UserAccessTokenLock {
protected readonly providerId = 'gitlab';
}
111 changes: 111 additions & 0 deletions apps/api/src/durable-objects/task-runner/workspace-steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,15 @@
return;
}

const projectRepo = await loadTaskRunnerProjectRepo(state, rc);
if (projectRepo?.repoProvider === 'artifacts') {
return;
}
if (projectRepo?.repoProvider === 'gitlab') {
await ensureGitLabBranchExistsOnRemote(state, rc, defaultBranch);
return;
}

// Parse owner/repo from repository string (format: "owner/repo")
const repoParts = state.config.repository.split('/');
if (repoParts.length !== 2 || !repoParts[0] || !repoParts[1]) {
Expand Down Expand Up @@ -303,6 +312,61 @@
}
}

type TaskRunnerProjectRepo = {
repoProvider: string | null;
};

async function loadTaskRunnerProjectRepo(
state: TaskRunnerState,
rc: TaskRunnerContext,
): Promise<TaskRunnerProjectRepo | null> {
return rc.env.DATABASE.prepare(
`SELECT repo_provider AS repoProvider FROM projects WHERE id = ?`
).bind(state.projectId).first<TaskRunnerProjectRepo>();
}

async function ensureGitLabBranchExistsOnRemote(
state: TaskRunnerState,
rc: TaskRunnerContext,
defaultBranch: string,
): Promise<void> {
try {
const { drizzle } = await import('drizzle-orm/d1');
const schema = await import('../../db/schema');
const { ensureGitLabBranchExists, getProjectGitLabRepository } = await import('../../services/gitlab');
const metadata = await getProjectGitLabRepository(
drizzle(rc.env.DATABASE, { schema }),
state.projectId
);
if (!metadata) {
log.warn('task_runner_do.ensure_branch.gitlab_metadata_missing', {
taskId: state.taskId,
projectId: state.projectId,
});
return;
}
const created = await ensureGitLabBranchExists({
env: rc.env,
userId: state.userId,
projectId: metadata.gitlabProjectId,
branch: state.config.branch,
ref: defaultBranch,
});
if (created) {
log.info('task_runner_do.ensure_branch.gitlab_ok', {
taskId: state.taskId,
branch: state.config.branch,
});
}
} catch (err) {
log.warn('task_runner_do.ensure_branch.gitlab_error', {
taskId: state.taskId,
branch: state.config.branch,
error: err instanceof Error ? err.message : String(err),
});
}
}

type TaskRunnerGitHubInstallation = {
installationId: string;
externalInstallationId: string | null;
Expand All @@ -328,11 +392,16 @@
const { signCallbackToken } = await import('../../services/jwt');
const { createWorkspaceOnNode } = await import('../../services/node-agent');
const callbackToken = await signCallbackToken(workspaceId, rc.env);
const gitSource = await resolveWorkspaceGitSource(state, rc);

const response = await createWorkspaceOnNode(nodeId, rc.env, state.userId, {
workspaceId,
repository: state.config.repository,
branch: state.config.branch,
repoProvider: gitSource.repoProvider,
cloneUrl: gitSource.cloneUrl,
repositoryHost: gitSource.repositoryHost,
repositoryPath: gitSource.repositoryPath,
callbackToken,
gitUserName: state.config.userName,
gitUserEmail: state.config.userEmail,
Expand All @@ -350,6 +419,48 @@
}
}

async function resolveWorkspaceGitSource(
state: TaskRunnerState,
rc: TaskRunnerContext,
): Promise<{
repoProvider: 'github' | 'artifacts' | 'gitlab';
cloneUrl: string | null;
repositoryHost: string | null;
repositoryPath: string | null;
}> {
const projectRepo = await loadTaskRunnerProjectRepo(state, rc);
const repoProvider = projectRepo?.repoProvider === 'gitlab'
? 'gitlab'
: projectRepo?.repoProvider === 'artifacts'
? 'artifacts'
: 'github';

Check warning on line 436 in apps/api/src/durable-objects/task-runner/workspace-steps.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=raphaeltm_simple-agent-manager&issues=AZ9CGiNXFT05Dg5qyvyf&open=AZ9CGiNXFT05Dg5qyvyf&pullRequest=1547

if (repoProvider !== 'gitlab') {
return { repoProvider, cloneUrl: null, repositoryHost: null, repositoryPath: null };
}

const { drizzle } = await import('drizzle-orm/d1');
const schema = await import('../../db/schema');
const { getProjectGitLabRepository } = await import('../../services/gitlab');
const metadata = await getProjectGitLabRepository(
drizzle(rc.env.DATABASE, { schema }),
state.projectId
);
if (!metadata) {
throw Object.assign(
new Error(`GitLab repository metadata is missing for project ${state.projectId}`),
{ permanent: true }
);
}

return {
repoProvider,
cloneUrl: metadata.httpUrlToRepo,
repositoryHost: metadata.host,
repositoryPath: metadata.pathWithNamespace,
};
}

function isWorkspaceDispatchAck(response: unknown, workspaceId: string): boolean {
if (!response || typeof response !== 'object') {
return false;
Expand Down
Loading