diff --git a/CHANGELOG.md b/CHANGELOG.md index a8de27a8d7..526c87632d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,33 @@ remain readable. ## October 1, 2026 +### Changed + +GitHub App sandbox credentials now reach only the session's repositories, including workspace-owned +sessions and members snapshotted from an environment. Team-owned sessions also intersect that set +with their team's current repository grants; an installation grant does not widen the credential +beyond the session's members. Sessions with no repositories or no remaining granted repositories +receive no token. Unresolved repository IDs and scopes exceeding GitHub's repository limit are +refused rather than falling back to installation-wide access. + +This breaks private submodule, repository-backed dependency, and sibling-clone setups unless those +repositories are included in the session's environment and, for team sessions, granted to its team. +Repository image builds receive a token for that repository alone; environment builds use only their +member repositories, intersected with the environment team's grants. Metadata and workspace-catalog +operations retain installation-wide access. GitLab still uses a deployment-wide PAT and does not +enforce repository-scoped credentials. + +Token cache keys cover the sorted, de-duplicated repository set, the process cache is bounded, and +overlapping refreshes share one mint per scope. Grant removal changes the next credential scope but +does not revoke already-issued tokens; sandbox helpers cache them until shortly before expiry. + +**Modal snapshot restores use brokered git credentials.** Restored sandboxes now fetch git +credentials from the control plane like fresh sessions, instead of receiving a token minted by +Modal. The control plane now sends the VCS host and clone username with every Modal create, restore, +and image-build request, so Modal no longer reads `SCM_PROVIDER` or needs GitHub App credentials. +Terraform no longer provisions Modal's `github-app` secret; you can delete the existing secret from +Modal after upgrading. + ### Added Team leads and workspace administrators can manage encrypted secrets from a team's Secrets tab. @@ -28,6 +55,30 @@ session ownership. Team-only legacy OAuth refresh tokens do not enable managed a keys remain usable. Team-secret read and decryption errors abort environment builds rather than falling back to other secret scopes. +**Team-owned environments.** The environment form offers team ownership, and team pages include an +Environments tab. Team environments are visible to their members and administrators, and controls +use server capabilities. Environment names are unique within each team. Sessions, including +inherited child targets, can use a team environment only when they belong to that team. Environment +secrets, settings, and image routes also require access to the owning environment. Workspace +environment management remains permission-based for custom roles, and existing environments retain +their ownership. The require-team creation setting also applies to new environments. Changing +environment secrets, settings, or images now also requires `environments.manage`, so custom roles +holding only `environments.secrets.manage`, `environments.settings.manage`, or +`environments.images.manage` lose those actions. Actorless bots see only workspace environments, +both in lists and by ID. + +**Team-owned automations.** The automation form offers team ownership, and team pages include an +Automations tab. Team automations are visible to their members and administrators, and controls use +server capabilities. Automation leads can manage team work and reassign departed executors; executor +reassignment verifies the candidate's launch permissions before writing and is audited as +`automation.executor_changed`. Selected environments must belong to the automation's team. +Executions require active membership, an unarchived team, and current repository grants, and their +sessions inherit the team's default visibility. Slack follow-ups use the persisted session's +collaboration decision, and automation history redacts inaccessible session metadata. Existing +automations retain their ownership and visibility. The require-team creation setting also applies to +new automations. These owned-resource checks apply in every session enforcement mode; source-control +token narrowing remains a separate change. + ### Removed Removed the team Activity tab and `GET /teams/:id/activity` endpoint. Team operations continue to be @@ -50,6 +101,22 @@ terminal access or per-session caches. Visibility changes require a changed sele non-private child-session cascades. Workspace audit readers can filter by teams they do not belong to. +### Added + +Teams now have a Repositories tab. Members can view grants; team leads and workspace administrators +can grant all installation repositories or select named repositories, and remove grants. Team-scoped +repository catalogs and repository-bearing writes check these grants, with explicit missing-grant +errors and audited grant changes. Workspace-level session catalogs remain installation-wide. Grant +changes advance the team's grant version but do not yet narrow or revoke sandbox installation +tokens. + +Workspace-level skills, repository secrets, and image builds retain existing permissions on +repositories granted to no team. Team-owned repositories additionally require membership (lead +membership for repository secrets), or workspace Owner/Administrator access, in every enforcement +mode. Manual team-owned environment builds require access to the owning team. Hidden and missing +team requests now record identical denied authorization decisions without changing their 404 +responses. + ## September 30, 2026 ### Added diff --git a/docs/AUTH.md b/docs/AUTH.md index 26d241f797..db16011670 100644 --- a/docs/AUTH.md +++ b/docs/AUTH.md @@ -199,9 +199,28 @@ selection; team selection in bots is a later phase, so their teamless creation A refused when the setting is enabled. Automation runs are exempt until automation team ownership is supported. The setting does not migrate or hide existing `ownerTeamId: null` workspace rows. -There is no repository-grant creation API or UI yet. Repository-backed team sessions without -existing grants are refused with `target_team_missing_grant`; creating a team does not grant it -repository access. Repository-less team sessions do not need repository grants. +Team leads and workspace Owners/Administrators manage repository grants in the team's Repositories +tab or through `/teams/:id/repository-grants`. Team members and workspace Owners/Administrators can +read the grants. A team can have either installation-wide access or named grants by SCM repository +ID, but not both. Creating a team does not grant repository access. Repository-backed team sessions +without covering grants are refused with `target_team_missing_grant`. Repository-less team sessions +do not need grants. Removing a grant advances the team's grant version and leaves existing +repository references intact; grants do not yet narrow or revoke sandbox installation tokens. + +Repository skills, repository secrets, and repository image builds remain workspace-level resources; +grants do not assign them to an owning team. They keep their existing permission checks when no team +grants the repository. Once any team grants it, callers must be current members of an active +granting team (leads for repository secrets), or be a workspace Owner or Administrator. Installation +grants count for every repository. Importing repository secrets into an environment checks the +source repository's workspace-level grant access as well as the destination owning team's coverage, +if the environment has an owning team. These checks apply in every `TEAMS_ENFORCEMENT` mode. + +Manual environment image builds instead follow the environment's owning team. For a team-owned +environment, the caller must be a current member of that team or a workspace Owner/Administrator, +and the active owning team must have grants covering every current repository in the environment. +Membership or grants in another team cannot replace that coverage, including for Owners and +Administrators. Workspace-level environment builds keep their existing checks. These rules apply in +every `TEAMS_ENFORCEMENT` mode. Sessions, automations, and environments cannot move between teams or between a team and the workspace. A session's owning team is fixed at creation: a team-owned session never becomes diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index ef5dd7aedc..ab4da2e4df 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -6,9 +6,35 @@ This guide walks you through deploying your own instance of Open-Inspect using T > [SETUP_GUIDE.md](./SETUP_GUIDE.md). > **Important**: This system is designed for **single-tenant deployment only**. All users share the -> same GitHub App credentials and can access any repository the App is installed on. See the +> deployment's GitHub App installation and workspace catalog. GitHub sandbox credentials reach only +> the session's repositories, further restricted by current grants for team-owned sessions. See the > [Security Model](../README.md#security-model-single-tenant-only) for details. +### Sandbox Repository Access + +GitHub credentials issued to a sandbox cover its primary repository and session members, including +the repository snapshot copied from an environment at creation. Workspace-owned sessions are scoped +the same way. For team-owned sessions, repositories no longer covered by the team's current grants +are removed from the next credential scope. An installation grant retains all session members but +does not grant the sandbox access to other repositories in the installation. + +Private submodules, repository-backed private dependencies, and setup scripts that clone sibling +repositories now fail unless those repositories are included in the session's environment. Team +sessions also need grants for those members. Add the dependencies to the environment before creating +the session; editing an environment does not expand an existing session's repository snapshot. + +Sessions with no repositories, no remaining granted members, or unresolved repository IDs receive no +token. Scopes with more than 500 unique IDs are refused. Repository image builds use that repository +alone, while environment image builds use their member repositories and the environment owner's +current grants. Metadata and catalog calls retain installation-wide credentials. GitLab deployments +still use one deployment-wide PAT and cannot provide this credential boundary. + +Removing a grant does not revoke a credential already issued to a sandbox. Installation tokens +remain valid until expiry, and credential helpers refresh shortly before expiry. Legacy session or +environment rows without numeric repository IDs require an identity-matching cached repository +catalog; loading the repository list populates that cache. Credentials fail closed if an ID cannot +be resolved from it. + --- ## Overview @@ -354,7 +380,7 @@ modal_workspace = "your-modal-workspace" modal_environment = "your-modal-environment" modal_environment_web_suffix = "your-modal-web-suffix" # Lowercase letters, digits, dashes; empty for https://workspace--... endpoints -# Sandbox provider: "modal" (default), "daytona", "vercel", "opencomputer", or "e2b" +# Sandbox provider: "modal" (default), "modal-vm", "daytona", "vercel", "opencomputer", or "e2b" # sandbox_provider = "modal" # Daytona (only required when sandbox_provider = "daytona") @@ -713,8 +739,9 @@ Your core deployment is complete. Everything below is optional; follow only the The core path uses Modal, the default `sandbox_provider`. To run sessions on another provider, set `sandbox_provider` in `terraform.tfvars` and follow the matching section below instead of the -[Modal](#modal) credentials in Step 2. Terraform accepts `modal`, `daytona`, `vercel`, -`opencomputer`, or `e2b`. +[Modal](#modal) credentials in Step 2. Terraform accepts `modal`, `modal-vm`, `daytona`, `vercel`, +`opencomputer`, or `e2b`. The `modal-vm` backend uses the same Modal credentials; see +[Modal VM backend](MODAL_DOCKER.md). ### Daytona @@ -1233,88 +1260,88 @@ may contain personal information; leave them in secrets if you prefer masking in The table below describes deployment settings and credentials; use Variables for the names above and Secrets for credentials: -| Setting Name | Value | -| ---------------------------------- | ------------------------------------------------------------------------------------------- | -| `CLOUDFLARE_API_TOKEN` | Your Cloudflare API token | -| `CLOUDFLARE_ACCOUNT_ID` | Your Cloudflare account ID | -| `CLOUDFLARE_WORKER_SUBDOMAIN` | Your workers.dev subdomain | -| `R2_MEDIA_LOCATION` | R2 location hint for the media bucket (defaults to `ENAM`) | -| `R2_MEDIA_BUCKET_NAME` | Optional media bucket name override for a pre-created bucket | -| `DEPLOYMENT_NAME` | Your deployment name | -| `R2_ACCESS_KEY_ID` | R2 access key ID | -| `R2_SECRET_ACCESS_KEY` | R2 secret access key | -| `WEB_PLATFORM` | `vercel` or `cloudflare` | -| `VERCEL_API_TOKEN` | Vercel API token _(only if `web_platform = "vercel"`)_ | -| `VERCEL_TEAM_ID` | Vercel team/account ID _(only if `web_platform = "vercel"`)_ | -| `VERCEL_PROJECT_ID` | Vercel project ID _(only if `web_platform = "vercel"`)_ | -| `DOCS_SITE_ENABLED` | `true` keeps the docs site Vercel project (default: `false`; see `packages/docs/README.md`) | -| `DOCS_CUSTOM_DOMAIN` | Optional docs site hostname, e.g. `docs.example.com` (default: the vercel.app URL only) | -| `MODAL_TOKEN_ID` | Modal token ID | -| `MODAL_TOKEN_SECRET` | Modal token secret | -| `MODAL_WORKSPACE` | Modal workspace name | -| `MODAL_ENVIRONMENT` | Modal environment name (defaults to `main`) | -| `MODAL_ENVIRONMENT_WEB_SUFFIX` | Modal environment web suffix for endpoint URLs; lowercase letters, digits, dashes, or empty | -| `SANDBOX_PROVIDER` | `modal` (default), `daytona`, `vercel`, `opencomputer`, or `e2b` | -| `SANDBOX_INACTIVITY_TIMEOUT_MS` | Idle milliseconds before a sandbox is snapshotted and stopped (defaults to `600000`) | -| `SANDBOX_BOOT_TIMEOUT_MS` | Milliseconds a connected sandbox may keep booting before it fails (defaults to `1800000`) | -| `DAYTONA_API_URL` | Daytona API URL _(only if `sandbox_provider = "daytona"`)_ | -| `DAYTONA_API_KEY` | Daytona API key _(only if `sandbox_provider = "daytona"`)_ | -| `DAYTONA_BASE_SNAPSHOT` | Daytona base snapshot name prefix _(only if `sandbox_provider = "daytona"`)_ | -| `DAYTONA_BASE_SNAPSHOT_MEMORY_GIB` | Base snapshot memory in GiB (defaults to `2`) | -| `DAYTONA_TARGET` | Optional Daytona target name | -| `DAYTONA_TOOLBOX_API_URL` | Optional Daytona toolbox proxy override; empty uses the proxy each sandbox reports | -| `DAYTONA_PREBUILDS_ENABLED` | `true` to admit new Daytona prebuilt-image builds and boot from them (default: `false`) | -| `VERCEL_SANDBOX_TOKEN` | Vercel API token _(only if `sandbox_provider = "vercel"`)_ | -| `VERCEL_SANDBOX_PROJECT_ID` | Vercel project ID for sandbox sessions _(only if `sandbox_provider = "vercel"`)_ | -| `VERCEL_SANDBOX_TEAM_ID` | Optional Vercel team/account ID for sandbox sessions | -| `VERCEL_BASE_SNAPSHOT_ID` | Optional manual Vercel base-runtime snapshot; skips Terraform-managed snapshot builds | -| `VERCEL_SANDBOX_RUNTIME` | Optional Vercel Sandbox runtime (defaults to `node24`) | -| `VERCEL_SNAPSHOT_EXPIRATION_MS` | Optional Vercel runtime snapshot expiration in milliseconds (`0` means no expiration) | -| `VERCEL_SANDBOX_API_BASE_URL` | Optional advanced Vercel Sandbox API base URL override | -| `OPENCOMPUTER_API_KEY` | OpenComputer API key _(only if `sandbox_provider = "opencomputer"`)_ | -| `E2B_API_KEY` | E2B API key _(only if `sandbox_provider = "e2b"`)_ | -| `GH_OAUTH_CLIENT_ID` | Optional GitHub sign-in client ID; set with `GH_OAUTH_CLIENT_SECRET` | -| `GH_OAUTH_CLIENT_SECRET` | Optional GitHub sign-in client secret; set with `GH_OAUTH_CLIENT_ID` | -| `GOOGLE_CLIENT_ID` | Optional Google sign-in client ID; set with `GOOGLE_CLIENT_SECRET` | -| `GOOGLE_CLIENT_SECRET` | Optional Google sign-in client secret; set with `GOOGLE_CLIENT_ID` | -| `GH_APP_ID` | Required GitHub App repository-access ID | -| `GH_APP_PRIVATE_KEY` | Required GitHub App repository-access private key (PKCS#8 format) | -| `GH_APP_INSTALLATION_ID` | Required GitHub App repository-access installation ID | -| `GH_REVIEWER_APP_ID` | Optional reviewer App ID; set with the three values below | -| `GH_REVIEWER_APP_PRIVATE_KEY` | Optional reviewer App private key (PKCS#8 format) | -| `GH_REVIEWER_APP_INSTALLATION_ID` | Optional reviewer App installation ID | -| `ENABLE_SLACK_BOT` | `true` to deploy Slack bot, `false` to skip (default: `true`) | -| `SLACK_BOT_TOKEN` | Slack bot token (required if enabled) | -| `SLACK_SIGNING_SECRET` | Slack signing secret (required if enabled) | -| `ENABLE_LINEAR_BOT` | `true` to deploy Linear bot, `false` to skip (default: `false`) | -| `LINEAR_CLIENT_ID` | Linear OAuth application client ID (required if Linear enabled) | -| `LINEAR_CLIENT_SECRET` | Linear OAuth application client secret (required if Linear enabled) | -| `LINEAR_WEBHOOK_SECRET` | Linear webhook signing secret (required if Linear enabled) | -| `LINEAR_API_KEY` | Optional Linear API key used as a comment-posting fallback | -| `ANTHROPIC_API_KEY` | Optional; reaches Modal and OpenComputer sandboxes; classifier fallback | -| `CLASSIFICATION_ANTHROPIC_API_KEY` | Optional classifier-only Anthropic key; never reaches sandboxes | -| `CLASSIFICATION_OPENAI_API_KEY` | Classifier OpenAI key (required when `classification_model` is an OpenAI id) | -| `OPENAI_API_KEY` | Optional OpenAI API key used when a session selects API-key authentication | -| `XAI_API_KEY` | Optional xAI API key used when a session selects API-key authentication | -| `DEEPSEEK_API_KEY` | DeepSeek API key (optional, required only for DeepSeek models) | -| `TOKEN_ENCRYPTION_KEY` | Generated encryption key (OAuth tokens) | -| `REPO_SECRETS_ENCRYPTION_KEY` | Generated encryption key (repo secrets) | -| `PROVIDER_ACCOUNTS_ENCRYPTION_KEY` | Optional existing provider-account key override; Terraform generates one when omitted | -| `MODAL_API_SECRET` | Generated Modal API secret | -| `NEXTAUTH_SECRET` | Generated browser-auth secret (legacy Actions secret name) | -| `ALLOWED_USERS` | Comma-separated GitHub usernames (empty = list unused; see note below) | -| `ALLOWED_EMAIL_DOMAINS` | Comma-separated email domains (empty = list unused; see note below) | -| `ALLOWED_EMAILS` | Comma-separated exact email addresses (for individual users on shared domains) | -| `ALLOWED_GITHUB_ORGS` | Comma-separated GitHub orgs whose active members can sign in | -| `UNSAFE_ALLOW_ALL_USERS` | `true` to allow any authenticated user when every allowlist is empty (defaults to `false`) | -| `ENABLE_DURABLE_OBJECT_BINDINGS` | Optional Terraform CI flag for Durable Object phase 1 (defaults to `true`) | -| `ENABLE_SERVICE_BINDINGS` | Optional Terraform CI flag for service-binding phase 1 (defaults to `true`) | -| `ENABLE_GITHUB_BOT` | `true` to deploy GitHub bot worker (or empty to skip) | -| `GH_WEBHOOK_SECRET` | GitHub webhook secret (required if GitHub bot enabled) | -| `GH_BOT_USERNAME` | GitHub App bot username, e.g., `my-app[bot]` (required if GitHub bot enabled) | -| `GH_REVIEWER_USERNAME` | Reviewer App login, e.g., `my-reviewer[bot]`; set with the three `GH_REVIEWER_APP_*` values | -| `APP_NAME` | Optional display name for whitelabeling (default: `Open-Inspect`) | -| `APP_ICON_URL` | Optional URL to a custom logo/favicon (default: built-in icon) | +| Setting Name | Value | +| ---------------------------------- | ----------------------------------------------------------------------------------------------- | +| `CLOUDFLARE_API_TOKEN` | Your Cloudflare API token | +| `CLOUDFLARE_ACCOUNT_ID` | Your Cloudflare account ID | +| `CLOUDFLARE_WORKER_SUBDOMAIN` | Your workers.dev subdomain | +| `R2_MEDIA_LOCATION` | R2 location hint for the media bucket (defaults to `ENAM`) | +| `R2_MEDIA_BUCKET_NAME` | Optional media bucket name override for a pre-created bucket | +| `DEPLOYMENT_NAME` | Your deployment name | +| `R2_ACCESS_KEY_ID` | R2 access key ID | +| `R2_SECRET_ACCESS_KEY` | R2 secret access key | +| `WEB_PLATFORM` | `vercel` or `cloudflare` | +| `VERCEL_API_TOKEN` | Vercel API token _(only if `web_platform = "vercel"`)_ | +| `VERCEL_TEAM_ID` | Vercel team/account ID _(only if `web_platform = "vercel"`)_ | +| `VERCEL_PROJECT_ID` | Vercel project ID _(only if `web_platform = "vercel"`)_ | +| `DOCS_SITE_ENABLED` | `true` keeps the docs site Vercel project (default: `false`; see `packages/docs/README.md`) | +| `DOCS_CUSTOM_DOMAIN` | Optional docs site hostname, e.g. `docs.example.com` (default: the vercel.app URL only) | +| `MODAL_TOKEN_ID` | Modal token ID | +| `MODAL_TOKEN_SECRET` | Modal token secret | +| `MODAL_WORKSPACE` | Modal workspace name | +| `MODAL_ENVIRONMENT` | Modal environment name (defaults to `main`) | +| `MODAL_ENVIRONMENT_WEB_SUFFIX` | Modal environment web suffix for endpoint URLs; lowercase letters, digits, dashes, or empty | +| `SANDBOX_PROVIDER` | `modal` (default), [`modal-vm`](MODAL_DOCKER.md), `daytona`, `vercel`, `opencomputer`, or `e2b` | +| `SANDBOX_INACTIVITY_TIMEOUT_MS` | Idle milliseconds before a sandbox is snapshotted and stopped (defaults to `600000`) | +| `SANDBOX_BOOT_TIMEOUT_MS` | Milliseconds a connected sandbox may keep booting before it fails (defaults to `1800000`) | +| `DAYTONA_API_URL` | Daytona API URL _(only if `sandbox_provider = "daytona"`)_ | +| `DAYTONA_API_KEY` | Daytona API key _(only if `sandbox_provider = "daytona"`)_ | +| `DAYTONA_BASE_SNAPSHOT` | Daytona base snapshot name prefix _(only if `sandbox_provider = "daytona"`)_ | +| `DAYTONA_BASE_SNAPSHOT_MEMORY_GIB` | Base snapshot memory in GiB (defaults to `2`) | +| `DAYTONA_TARGET` | Optional Daytona target name | +| `DAYTONA_TOOLBOX_API_URL` | Optional Daytona toolbox proxy override; empty uses the proxy each sandbox reports | +| `DAYTONA_PREBUILDS_ENABLED` | `true` to admit new Daytona prebuilt-image builds and boot from them (default: `false`) | +| `VERCEL_SANDBOX_TOKEN` | Vercel API token _(only if `sandbox_provider = "vercel"`)_ | +| `VERCEL_SANDBOX_PROJECT_ID` | Vercel project ID for sandbox sessions _(only if `sandbox_provider = "vercel"`)_ | +| `VERCEL_SANDBOX_TEAM_ID` | Optional Vercel team/account ID for sandbox sessions | +| `VERCEL_BASE_SNAPSHOT_ID` | Optional manual Vercel base-runtime snapshot; skips Terraform-managed snapshot builds | +| `VERCEL_SANDBOX_RUNTIME` | Optional Vercel Sandbox runtime (defaults to `node24`) | +| `VERCEL_SNAPSHOT_EXPIRATION_MS` | Optional Vercel runtime snapshot expiration in milliseconds (`0` means no expiration) | +| `VERCEL_SANDBOX_API_BASE_URL` | Optional advanced Vercel Sandbox API base URL override | +| `OPENCOMPUTER_API_KEY` | OpenComputer API key _(only if `sandbox_provider = "opencomputer"`)_ | +| `E2B_API_KEY` | E2B API key _(only if `sandbox_provider = "e2b"`)_ | +| `GH_OAUTH_CLIENT_ID` | Optional GitHub sign-in client ID; set with `GH_OAUTH_CLIENT_SECRET` | +| `GH_OAUTH_CLIENT_SECRET` | Optional GitHub sign-in client secret; set with `GH_OAUTH_CLIENT_ID` | +| `GOOGLE_CLIENT_ID` | Optional Google sign-in client ID; set with `GOOGLE_CLIENT_SECRET` | +| `GOOGLE_CLIENT_SECRET` | Optional Google sign-in client secret; set with `GOOGLE_CLIENT_ID` | +| `GH_APP_ID` | Required GitHub App repository-access ID | +| `GH_APP_PRIVATE_KEY` | Required GitHub App repository-access private key (PKCS#8 format) | +| `GH_APP_INSTALLATION_ID` | Required GitHub App repository-access installation ID | +| `GH_REVIEWER_APP_ID` | Optional reviewer App ID; set with the three values below | +| `GH_REVIEWER_APP_PRIVATE_KEY` | Optional reviewer App private key (PKCS#8 format) | +| `GH_REVIEWER_APP_INSTALLATION_ID` | Optional reviewer App installation ID | +| `ENABLE_SLACK_BOT` | `true` to deploy Slack bot, `false` to skip (default: `true`) | +| `SLACK_BOT_TOKEN` | Slack bot token (required if enabled) | +| `SLACK_SIGNING_SECRET` | Slack signing secret (required if enabled) | +| `ENABLE_LINEAR_BOT` | `true` to deploy Linear bot, `false` to skip (default: `false`) | +| `LINEAR_CLIENT_ID` | Linear OAuth application client ID (required if Linear enabled) | +| `LINEAR_CLIENT_SECRET` | Linear OAuth application client secret (required if Linear enabled) | +| `LINEAR_WEBHOOK_SECRET` | Linear webhook signing secret (required if Linear enabled) | +| `LINEAR_API_KEY` | Optional Linear API key used as a comment-posting fallback | +| `ANTHROPIC_API_KEY` | Optional; reaches Modal and OpenComputer sandboxes; classifier fallback | +| `CLASSIFICATION_ANTHROPIC_API_KEY` | Optional classifier-only Anthropic key; never reaches sandboxes | +| `CLASSIFICATION_OPENAI_API_KEY` | Classifier OpenAI key (required when `classification_model` is an OpenAI id) | +| `OPENAI_API_KEY` | Optional OpenAI API key used when a session selects API-key authentication | +| `XAI_API_KEY` | Optional xAI API key used when a session selects API-key authentication | +| `DEEPSEEK_API_KEY` | DeepSeek API key (optional, required only for DeepSeek models) | +| `TOKEN_ENCRYPTION_KEY` | Generated encryption key (OAuth tokens) | +| `REPO_SECRETS_ENCRYPTION_KEY` | Generated encryption key (repo secrets) | +| `PROVIDER_ACCOUNTS_ENCRYPTION_KEY` | Optional existing provider-account key override; Terraform generates one when omitted | +| `MODAL_API_SECRET` | Generated Modal API secret | +| `NEXTAUTH_SECRET` | Generated browser-auth secret (legacy Actions secret name) | +| `ALLOWED_USERS` | Comma-separated GitHub usernames (empty = list unused; see note below) | +| `ALLOWED_EMAIL_DOMAINS` | Comma-separated email domains (empty = list unused; see note below) | +| `ALLOWED_EMAILS` | Comma-separated exact email addresses (for individual users on shared domains) | +| `ALLOWED_GITHUB_ORGS` | Comma-separated GitHub orgs whose active members can sign in | +| `UNSAFE_ALLOW_ALL_USERS` | `true` to allow any authenticated user when every allowlist is empty (defaults to `false`) | +| `ENABLE_DURABLE_OBJECT_BINDINGS` | Optional Terraform CI flag for Durable Object phase 1 (defaults to `true`) | +| `ENABLE_SERVICE_BINDINGS` | Optional Terraform CI flag for service-binding phase 1 (defaults to `true`) | +| `ENABLE_GITHUB_BOT` | `true` to deploy GitHub bot worker (or empty to skip) | +| `GH_WEBHOOK_SECRET` | GitHub webhook secret (required if GitHub bot enabled) | +| `GH_BOT_USERNAME` | GitHub App bot username, e.g., `my-app[bot]` (required if GitHub bot enabled) | +| `GH_REVIEWER_USERNAME` | Reviewer App login, e.g., `my-reviewer[bot]`; set with the three `GH_REVIEWER_APP_*` values | +| `APP_NAME` | Optional display name for whitelabeling (default: `Open-Inspect`) | +| `APP_ICON_URL` | Optional URL to a custom logo/favicon (default: built-in icon) | An empty allowlist only means that list is not used; it does not admit everyone. Terraform fails the plan unless at least one of `ALLOWED_USERS`, `ALLOWED_EMAIL_DOMAINS`, `ALLOWED_EMAILS`, or diff --git a/docs/HOW_IT_WORKS.md b/docs/HOW_IT_WORKS.md index e24eb5ff93..b4b4946173 100644 --- a/docs/HOW_IT_WORKS.md +++ b/docs/HOW_IT_WORKS.md @@ -688,13 +688,12 @@ was built for internal use where all employees have access to company repositori | WebSocket Token | Authenticate client connections | Single session | | Managed LLM Token | Short-lived OpenAI or xAI model access | Pinned session provider account | -Fresh and prebuilt-image sandboxes fetch git credentials on demand through the control plane instead -of relying on a token embedded in the environment or remote URL. Snapshot restores may still receive -env-token fallbacks so legacy snapshots can boot through the credential-helper migration. The helper -authorizes HTTPS requests for the configured SCM host, preserving existing setup/start hooks that -clone other private repositories available to the installation. This primarily protects continuously -running sessions and Daytona persistent resumes from expired embedded credentials; Modal snapshot -restores still mint a fresh fallback token on restore. +Session sandboxes, whether fresh, prebuilt-image, or restored from a snapshot, fetch git credentials +on demand through the control plane instead of relying on a token embedded in the environment or +remote URL. The helper authorizes HTTPS requests for the configured SCM host, preserving existing +setup/start hooks that clone other private repositories available to the installation. This protects +continuously running sessions and persistent resumes from expired embedded credentials. One-shot +image builds still receive `VCS_CLONE_TOKEN` because they have no session to broker through. ### Secrets diff --git a/docs/IMAGE_PREBUILD.md b/docs/IMAGE_PREBUILD.md index 86cc4237a9..df48742664 100644 --- a/docs/IMAGE_PREBUILD.md +++ b/docs/IMAGE_PREBUILD.md @@ -34,9 +34,11 @@ few minutes of changes. ## Getting Started Pre-built images are available when the deployment uses `sandbox_provider = "modal"`, -`sandbox_provider = "vercel"`, `sandbox_provider = "opencomputer"`, `sandbox_provider = "e2b"`, or -`sandbox_provider = "daytona"`. The artifact is stored per provider as a Modal image, Vercel -snapshot, OpenComputer checkpoint, or E2B/Daytona snapshot. +`sandbox_provider = "modal-vm"`, `sandbox_provider = "vercel"`, `sandbox_provider = "opencomputer"`, +`sandbox_provider = "e2b"`, or `sandbox_provider = "daytona"`. The artifact is stored per provider +as a Modal image, Vercel snapshot, OpenComputer checkpoint, or E2B/Daytona snapshot. With +`modal-vm`, the artifact is a Modal image built on the VM backend. Images built under `modal` are +not used by `modal-vm` sessions. Daytona additionally requires an operator to open admission (`daytona_prebuilds_enabled`, default off) — see [Daytona prebuilds](#daytona-prebuilds) below. While admission is closed the settings diff --git a/docs/MODAL_DOCKER.md b/docs/MODAL_DOCKER.md index 7c21617f42..521ad32914 100644 --- a/docs/MODAL_DOCKER.md +++ b/docs/MODAL_DOCKER.md @@ -55,10 +55,12 @@ Containers must use appropriate persistence and restart policies. Live Docker pa provided. Raw daemon logs are truncated after clean preparation before reusable image capture. Retried VM launches adopt only an exactly owned allocation and recover its original interactive -credentials. A predecessor must be confirmed terminated before launching a replacement. Build -allocations have deterministic backend/build names, so a retried create adopts the allocation an -earlier lost response created. Returned build handles are persisted for cleanup before backend -validation; incompatible builds never start and cannot publish prepared images. +credentials. A predecessor must be confirmed terminated before launching a replacement. A build +whose create response is lost is marked failed; its VM runs until the provider's build sandbox +timeout (40 minutes by default, up to 70 minutes, including the 10-minute finalization grace). +Re-triggering the build uses a new build ID and allocation name, not the previous allocation. +Returned build handles are persisted for cleanup before backend validation; incompatible builds +never start and cannot publish prepared images. Before create or restore returns, the control plane records a pending VM reference of the form `modal-vm-session:["sessionId","sandboxId"]`. The session id selects the named allocation; the @@ -122,6 +124,24 @@ credentials for pending cleanup, and rebuild images under the selected backend. artifacts. Rollback to `modal` does not transparently resume VM sessions or prove old VMs have stopped. +After changing `sandbox_provider`, apply Terraform and ensure the web app is deployed with the new +provider. The deployment step depends on `web_platform`: + +- **Cloudflare**: `terraform apply` rebuilds and deploys the web Worker with the new provider. No + separate redeploy step is needed. +- **Vercel**: after the apply, create a new production deployment through your configured CLI, + Git-linked, or GitHub Actions deployment path. If using Actions, manually run **Deploy Web**; + Terraform-only changes do not trigger it, and it skips deployment unless `VERCEL_API_TOKEN` and + `VERCEL_PROJECT_ID` are configured in GitHub. See + [Deploy the Web App](GETTING_STARTED.md#step-7-deploy-the-web-app) for the CLI and Git-linked + paths. + +Terraform updates `NEXT_PUBLIC_SANDBOX_PROVIDER`, but Vercel environment changes apply only to new +deployments and `NEXT_PUBLIC_*` values are fixed at build time. Until the new Vercel deployment is +live, `GET /api/image-builds` still filters by the old provider, so the Pre-Built Images page shows +the old provider's builds. This applies to any provider switch, not only switches to or from +`modal-vm`. + PR #2007's earlier per-session Docker/variant design was not deployed. Its schema additions and settings are not part of this implementation, so no variant migration is required. diff --git a/docs/plans/sandbox-lifecycle-manager-refactor.md b/docs/plans/sandbox-lifecycle-manager-refactor.md index 052f305573..f2de4ea35a 100644 --- a/docs/plans/sandbox-lifecycle-manager-refactor.md +++ b/docs/plans/sandbox-lifecycle-manager-refactor.md @@ -6,6 +6,11 @@ refactor. [ADR 0004](../adr/0004-sandbox-checkpoint-and-shutdown.md) governs lif shutdown authority. The [characterization matrix](sandbox-lifecycle-refactor-baseline.md) records compatibility evidence and separate behavior gaps, not fixes claimed by extraction. +T7's combined audit, real-storage wiring evidence and validation are supplied by +[PR #2203](https://github.com/ColeMurray/background-agents/pull/2203). Command results and release +limits are in the [closure report](sandbox-lifecycle-refactor-verification.md). A green local suite +is not deployment authorization or exhaustive interleaving safety. + ## Ownership Paths are relative to `packages/control-plane/src/`. @@ -30,6 +35,10 @@ Paths are relative to `packages/control-plane/src/`. admission flags and prior-generation replacement retirement remain in the manager. - `sandbox/lifecycle/provider-stop.ts` owns the shared local stop bound, abort/timer mechanics and explicit `confirmed`/`not_stopped` outcome contract, not either caller's retirement policy. +- `sandbox/lifecycle/watchdog-effects.ts` owns stateless connect-timeout, stale-heartbeat, stale + snapshot/stop and inactivity effects. The manager retains the complete boot-budget effect, + captures alarm facts, evaluates/dispatches policy, schedules healthy/warning checks and owns the + termination guard. - `session/sandbox-repository.ts` owns conditional SQL and encrypted access storage. `session/sandbox-shutdown.ts` and `sandbox-shutdown-repository.ts` own the durable shutdown/checkpoint protocol, receipts, holds, source retirement and recovery. @@ -37,9 +46,10 @@ Paths are relative to `packages/control-plane/src/`. ports remain in `sandbox/lifecycle/ports.ts`; consumers do not gain launch, access or shutdown internals. Session access readers retain authentication/read eligibility and decryption rechecks. -Watchdog effects remain manager responsibilities until their serial extraction increment lands. Each -increment must integrate before its successor; the manager is not intended to become a tiny facade -or lose lifecycle arbitration. +Each increment must integrate before its successor; the manager is not intended to become a tiny +facade or lose lifecycle arbitration. Fatal/unresponsive termination, generic startup claims, +failure accounting and public checkpoint/admission/recovery policies remain manager +responsibilities. ## Launch Contract @@ -210,6 +220,63 @@ These inherited semantics, along with prior-generation unscoped clearing and acc failure boundaries recorded in the baseline, are unchanged; no stronger retirement/exactly-once guarantee is claimed. +## Watchdog Contract + +`failConnectTimeout`, `terminateStaleHeartbeat`, `snapshotAndStopStaleSandbox` and +`stopForInactivity` are stateless functions. Their dependencies are narrowed storage, broadcast, +socket, shutdown and access ports plus named failure accounting/reporting, checkpoint triggering, +provider capability checks and generation-targeted best-effort stop operations. Each function takes +only its dependency subset; there is no manager reference, flag setter, scheduler or lifecycle +class. Construction does not call dependencies, and logger resolution remains lazy, including +detached snapshot rejection logging. Existing boot-phase parsing and failure text are reused without +new vocabulary, defaults or messages. + +Review follow-up keeps `failBootBudget` entirely in the manager, alongside its termination/admission +state. This replaces the effect-to-manager `stopBootBudgetSandbox` callback and private guard +wrapper; the complete ordering and guarded stop are visible in one method. Moving termination +ownership or splitting this operation behind a one-off reverse callback is not part of this +increment. + +`handleAlarm` retains the initial shutdown hold check, pre-await sandbox/time/client/provider-handle +capture, ID-plus-reservation-timestamp generation predicate, `evaluateAlarmPolicy` dispatch, +healthy/warning scheduling and `SandboxAlarmResult`. `session/alarm/handler.ts` still prioritizes +shutdown before and after other alarm work and uses those same results to fail/redrive the queue. +The shared `AlarmScheduler` remains the only alarm/deadline owner. + +- Connect timeout fails/counts/clears access before explicit stop, fences only when stop is + available and the retained source is not held, then publishes status/error after stop. Without + explicit stop, a late bridge may still self-heal the unfenced failed boot. +- Heartbeat retires incomplete boots without snapshotting, preserves provider-managed resumable + state, and delegates shutdown-required ready-workspace capture to shutdown in emergency mode. + Legacy ready-workspace capture precedes explicit destroy-stop, then runtime shutdown and final + detach. Without explicit stop, capture stays detached. Existing hold/generation checks after + awaited capture and generation checks after stop remain where they were; no new confirmation + episode is introduced. +- Boot budget holds a failed retained source without runtime shutdown, fencing, detach or provider + destruction. Otherwise it sends runtime shutdown, fences, fails/counts/retires access, publishes + and persists the failure, then detaches. Within the same manager method, only explicit + destroy-stop runs inside the `isTerminatingSandbox` try/finally interval. Concurrent spawn is + excluded during that stop, not during earlier publication/detachment. Other watchdog paths + intentionally do not acquire this guard; fatal termination keeps its own interval. +- Inactivity awaits shutdown ownership first and falls back only when unmanaged. Access retirement + retains capability-based URL/secret rules. Resumable stop skips snapshot/runtime shutdown; legacy + capture precedes runtime shutdown, explicit destroy-stop and final detach/warning. Existing + post-capture hold/generation and post-stop generation checks remain unchanged. + +Stops receive the observed provider handle and generation timestamp when the captured handle exists. +An inherited exception remains: a captured absent handle is passed as `undefined`, which permits the +manager's stop adapter to reread the current row. This can retarget a replacement after an awaited +unmanaged shutdown decision. T7 characterizes that trace, including unscoped successor access/status +retirement, and connect-timeout error publication after replacement. Neither is fixed by extraction. +Other retirement exceptions retain their existing boundaries. The baseline's separate gaps remain +separate behavior work, not extraction fixes or exhaustive interleaving guarantees. + +Related-work reconciliation at T6's starting `60930ce`: COL-245 is integrated via PR #2170, after +the launch/access/VM increments. COL-238 remains In Progress and PR #2141 is open/unmerged; its +proposed heartbeat confirmation is absent from this checkout and is not imported. COL-150's alarm +policy and the current COL-151 boot-phase helpers are reused unchanged. Recheck independently landed +heartbeat work when integrating subsequent increments. + ## Verification Keep direct narrow-dependency tests for input resolution and lookup effects. Keep assembled tests @@ -243,6 +310,48 @@ hold, exercises the actual rehydration hook/shared deadline storage, and confirm failed/successful/repeated cleanup preserves the hold and recovery receipt. ESLint keeps cleanup internals unavailable to public consumers. +Watchdog coverage retains all assembled manager/policy/effect suites and real-storage/Workerd alarm +recovery and shutdown checks. The boot-budget trace names failure accounting, access retirement, +status/error publication and persistence before detachment and guarded stop; actual spawn remains +blocked during stop and resumes after stop failure. Retained-source tests assert no send/detach and +no duplicate failure charge under the hold. Deferred heartbeat/inactivity stop tests independently +replace ID or timestamp for legacy and resumable providers and assert post-stop abandonment without +successor mutation, detachment or new publication. Reads return fresh row snapshots rather than +aliases of the persisted fixture row; the captured alarm row remains unchanged across writes. +Same-ID/timestamp continuations vary status, provider handle, heartbeat/activity and access URL to +prove non-identity changes do not abandon the generation. Deferred ordinary captures prove in-flight +and uncertain ownership blocks competing generic teardown and subsequent stop; inactivity ownership +tests pin the absence of fallback effects before a held/owned decision. These extend rather than +replace the existing half-boot/ready, destructive-snapshot, detached-capture, late-bridge, +checkpoint-replacement, queue-result, duplicate-alarm and shared-deadline tests. ESLint keeps +watchdog internals unavailable to public consumers. + +T7's `test/integration/sandbox-vm-reconciliation.test.ts` assembles the production runtime with real +Workerd SQL/encryption and substituted provider/socket transport. It reconstructs a persisted +foreground reservation, completes bridge lookup through attachment/readiness, and separately changes +the pending reference or reservation timestamp after real ciphertext is produced. A refused atomic +completion causes no shutdown adoption or access publication. Successful reconstruction preserves +early readiness and conservative lifetime, decrypts access through a reconstructed repository, and +cannot mint terminal access from the persisted hash. It also pins current admission semantics: +acknowledgement plus known pending lifetime can admit work before lookup finishes after +reconstruction; the original instance's foreground-pending flag still blocks it. These gates are not +equivalent. + +An explicitly ambiguous original create subsequently settles through foreground lookup. +Post-settlement assertions retain supersession refusal and distinguish the original instance's +still-live terminal signing key from the reconstructed instance's lack of one. Tests select the +single named VM-resolution task, not the composition root's whole background-task list. + +The rejected-reconstruction test now enters `SessionServer.onScheduledDeadline()` rather than +calling only manager shutdown processing. It covers the production pre/post-projection passes, +durable retry-before-stop, delivery acknowledgement and duplicate wake-ups under a retained shutdown +hold. It asserts each stop target/intent and the real host alarm, consumes that alarm before +simulated due delivery, and verifies failure rearming and the final retry's no-op drain after +successful cleanup. Existing assembled-manager, real-SQL, Workerd, scheduler and Node conformance +suites remain intact. ESLint additionally protects launch-context and startup-error internals using +the existing import rules, including extension-bearing imports; composition alone supplies launch +integration ports. + `manager-shutdown.test.ts` isolates the assembled shutdown/recovery cases, including the committed access/publication failure matrix, from the manager's orchestration suite. It retains real manager/access/shutdown composition with test storage/provider ports; real SQLite and Workerd checks @@ -254,15 +363,17 @@ Build shared first, then run sequentially from the repository root: ```bash npm run build -w @open-inspect/shared -npm test -w @open-inspect/control-plane -- src/sandbox/lifecycle src/sandbox/providers src/session/sandbox-access src/session/sandbox-repository src/session/sandbox-shutdown -npm run test:integration -w @open-inspect/control-plane -- sandbox-early-connect sandbox-shutdown sandbox-state-retention session-components +npm test -w @open-inspect/control-plane -- --maxWorkers=1 +npm run test:integration -w @open-inspect/control-plane -- --maxWorkers=1 npm run typecheck -w @open-inspect/control-plane +npm run build -w @open-inspect/control-plane npm run lint -w @open-inspect/control-plane npm run test:lint-sandbox-boundaries +npm run format:check git diff --check ``` -Check formatting of touched files. Record checkout details, exact command results and blockers in -the PR/issue handoff rather than this enduring ownership guide. Full-story/package/bundle checks -remain the final increment's scope. Provider substitutes are not live-provider verification, and -this refactor does not authorize deployment or claim exhaustive interleaving safety. +The worker flag bounds sandbox resource use without selecting a subset of tests. Record checkout +details, exact command results and blockers in the closure report and PR/issue handoff rather than +this enduring ownership guide. Provider substitutes are not live-provider verification, and this +refactor does not authorize deployment or claim exhaustive interleaving safety. diff --git a/docs/plans/sandbox-lifecycle-refactor-verification.md b/docs/plans/sandbox-lifecycle-refactor-verification.md new file mode 100644 index 0000000000..e861ade3e0 --- /dev/null +++ b/docs/plans/sandbox-lifecycle-refactor-verification.md @@ -0,0 +1,265 @@ +# Sandbox Lifecycle Refactor: Final Verification + +This is COL-247's closure evidence for [COL-240](https://linear.app/colemurray/issue/COL-240). The +[ownership guide](sandbox-lifecycle-manager-refactor.md) and +[ADR 0004](../adr/0004-sandbox-checkpoint-and-shutdown.md) remain the architecture specification; +the [T1 report](sandbox-lifecycle-refactor-baseline.md) is historical baseline evidence. + +## Revision and Integration + +- Research baseline: `eef911f36e704fc49104546a9cd52b584d8b9223`. +- Audited starting HEAD: `b98a378bdbe2dba1237953da9162e8a85a7e926a`, initially clean. +- Tested checkout: that HEAD plus this PR's boundary rules, composition import comment, regression + tests and documentation. No lifecycle/provider/repository runtime implementation changes in T7. +- Environment: Node `v24.20.0`, installed workspace dependencies, repository root as working + directory. +- Validation status: all required checks passed locally, subject to the existing KV TTL skip and + release/evidence limits below. T7 still requires review/merge. No deployment or live-provider + checks. + +Actual history and combined source were reviewed, not just the watchdog extraction diff: + +| Increment | Integrated Main Commit | Evidence | +| ------------ | ------------------------------------ | --------------------------------------------------------------------------- | +| T1 / COL-241 | `0ce9e9d` (#2144) | Characterization matrix and assembled/real-storage regressions | +| T2 / COL-242 | `44ca1a9` (#2148), `0318fd9` (#2149) | Launch context, then class-based construction | +| T3 / COL-243 | `d3de8c0` (#2151) | Access mechanics and direct shutdown retirement wiring | +| T4 / COL-244 | `64aa395` (#2166) | Five VM fields and reconciliation moved together | +| T5 / COL-245 | `60930ce` (#2170) | Stateless cleanup and shared bounded-stop operation | +| T6 / COL-246 | `b98a378` (#2172) | Stateless watchdog effects; complete boot-budget operation stays in manager | +| T7 / COL-247 | This PR, review/merge pending | Combined audit, boundary checks, wiring regressions and full validation | + +## State and Dependency Owners + +Paths below are relative to `packages/control-plane/src/`. + +| State or Responsibility | Sole Owner | +| ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `isSpawningSandbox`, `isTerminatingSandbox`, `providerStartupPending` | `sandbox/lifecycle/manager.ts` | +| Lazy `logMemo` | Manager; collaborators receive a lazy logger operation | +| `bridgeResolution`, `bridgeRetryGeneration`, `bridgeStartupClaim`, `bridgeResolvedStartup`, `vmStartupAuth` | `vm-startup-reconciliation.ts`; foreground object identity remains distinct from bridge field equality | +| Launch inputs/settings/images | Readonly `SandboxLaunchContext`; no lifecycle flags, provider operations or authorization | +| Access signing/reuse/retirement/notification | `SandboxAccess`; no retained signing key, eligibility or lifecycle state | +| Rejected/late cleanup and local stop bound | Stateless `allocation-cleanup.ts` and `provider-stop.ts`; no new durable record | +| Connect/heartbeat/snapshot/inactivity effects | Stateless `watchdog-effects.ts`; no flags, scheduler or full manager reference | +| Conditional SQL and encryption | `session/sandbox-repository.ts`, including atomic post-encryption resume/bridge checks | +| Durable checkpoint/shutdown/holds/receipts/recovery | `SandboxShutdownCoordinator` and `sandbox-shutdown-repository.ts` | +| Shared pending/in-flight deadlines | `session/alarm/scheduler.ts`, not extracted collaborators | + +Manager retains startup selection/orchestration, synchronous identity reservation, generic provider +claims, rejection policy, breaker/error ownership, readiness and queue/push admission, public +recovery, cancellation/archive policy, prior-generation retirement, fatal/unresponsive termination, +alarm capture/dispatch and the entire boot-budget effect. Only explicit boot-budget provider stop +owns that path's termination-guard interval, after publication and detach. No universal mutex was +introduced. + +Composition still constructs repository/socket/messenger leaves, access, shutdown, then manager. +Shutdown calls access retirement directly, not a manager forwarding callback. Constructors do not +invoke runtime work; deferred queue callbacks run after construction. VM's only reverse manager +operation is resolved startup acceptance. Watchdog callbacks expose named manager-owned operations, +not mutable context or flag setters. Repositories implement internal contracts structurally. + +Consumer ports remain separate from internal contracts. Authenticator, runtime handlers, queue, +execution stop, push, HTTP lifecycle, access readers and alarm handling retain their existing public +ports. Cloudflare and Node compose the same application graph through platform ports. No extraction +introduced full-manager consumer access or a second lifecycle/durable shutdown authority. + +## Compatibility and Traces + +No T7 public API, SQL schema, stored/wire shape, runtime/backend protocol, timeout, retry, setting, +log-event/message or provider-stop argument changes. Manager constructor wiring and internal type +locations intentionally changed in earlier extractions; they are not external consumer APIs. +Consumer `ports.ts`, lifecycle adapters, production sandbox repository, pure decisions/alarm policy, +platform ports and both platform entry points are unchanged from the research baseline. + +The combined audit preserves these trace boundaries: + +- Reservation commits sandbox identity and shutdown ownership synchronously, announces after commit, + invalidates credentials before hashing, and conditionally publishes the hash before launch reads. +- Fresh retains environment/image/MCP/Slack order; restore retains environment/Slack/MCP order and + pending registration immediately before synchronous recovery-invoked recording/provider dispatch. + Resume gains no unrelated launch inputs. Confirmed-unavailable prebuilt retry rotates + identity/auth. +- Unknown VM outcomes remain lookup-only; nullable foreground outcomes authorize neither replay nor + destruction. Foreground finalization retains generation object identity and deferred token + handoff. +- Provider completion, attachment, runtime readiness, lifetime, generation acknowledgement, queue + admission and live push remain distinct. A held-current result is neither adopted nor destroyed. +- Resume/bridge encrypt before atomic eligibility checks; only bridge supplies expected reference. + Fresh/restore retain separate artifact writes. Terminal expiry/hash-only restart cannot renew + JWTs. +- Rejection fences and retains cleanup before I/O. Shared retry is persisted before stop, matching + completion clears only its handle, and rejected cleanup precedes shutdown holds/watchdogs. +- Watchdogs retain captured generation checks, path-specific snapshot/stop/send/fence ordering, + return values and queue redrive. Breaker charging stays with the failure writer and resets only + after successful prompt dispatch. Logger/background resolution remains lazy/synchronously started + at the original entry points. Local abort/timeout does not establish remote cancellation. + +Two explicit reviewed fixes in earlier extraction PRs are exceptions to a purely mechanical claim: +COL-244 suppresses access announcement when deferred acceptance is refused; COL-245 requires an +actually dispatched, successful stop before confirmation-dependent cleanup/replacement. They are +documented in the ownership guide, not attributed to T7 or claimed to close broader safety gaps. + +## Executed Evidence + +Existing named evidence is retained in the T1 matrix and ownership guide, including assembled +manager launch/VM/cleanup/watchdog tests, real SQL/encryption tests, Workerd +recovery/early-connect/shutdown tests, scheduler/handler tests and Node in-memory/file-backed +storage conformance. + +T7 adds only missing wiring or explicit inherited-gap reproducers: + +- `test/integration/sandbox-vm-reconciliation.test.ts`: production runtime reconstructed over an + actual foreground pending reservation. Real encryption is gated after ciphertext creation; + changing only expected reference or reservation timestamp rejects completion without row/shutdown + mutation or publication. Success retains early readiness/conservative lifetime, decryptable access + and no restarted terminal signing authority. Admission is tested separately from lookup + completion. +- `test/integration/sandbox-state-retention.test.ts`: reconstructed rejected cleanup now runs + through production scheduled delivery and both shutdown-priority passes. Retry exists during + provider I/O; failure preserves handle/hold/receipt; success conditionally clears; duplicate + delivery does not stop again; the in-flight deadline is acknowledged. +- `src/sandbox/lifecycle/alarm-effects.test.ts`: two controlled assembled characterization traces + record inherited successor retirement/absent-handle retargeting and post-stop failure publication. +- `scripts/lint-sandbox-boundaries.test.mjs`: existing ESLint checks now include launch-context and + startup-errors, extension-bearing imports and permitted internal uses; no bespoke scanner/rule + weakening. Composition's launch-port import has an explicit, justified exemption. + +The full results below record the initial `3b9e7a0` handoff; the test-only review follow-up has +focused validation recorded separately below. Checks ran sequentially, with one Vitest worker to +avoid starving the sandbox. Log redirection under `/tmp/opencode/col247-*.log` does not alter the +commands or selections below. + +| Exact Command | Exit / Result | +| ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `npm run build -w @open-inspect/shared` | 0; passed | +| `npm test -w @open-inspect/control-plane -- --maxWorkers=1` | 0; 359 files / 6,114 tests passed (126.13 s) | +| `npm run test:integration -w @open-inspect/control-plane -- --maxWorkers=1` | Terminal timeout at 600 s; no completion result, not a pass | +| `npm run test:integration -w @open-inspect/control-plane -- --maxWorkers=1 --reporter=verbose` | 0; 137 files / 1,742 tests passed, 1 existing skip (final rerun 647.39 s) | +| `npm run typecheck -w @open-inspect/control-plane` | 0; Worker, Node, unit and integration configurations passed | +| `npm run build -w @open-inspect/control-plane` | 0; Worker and Node bundles passed (4.8 MB / 6.6 MB; esbuild size warnings) | +| `npm run lint -w @open-inspect/control-plane` | 0; no errors or warnings | +| `npm run test:lint-sandbox-boundaries` | 0; 2 tests passed | +| `npm run format:check` | 0; entire repository passed | +| `git diff --check` | 0; passed | + +The Workerd skip is `cache-store-conformance.test.ts`, Cloudflare KV "reads null once the TTL has +passed": that backend does not offer the suite's controllable clock. It is preexisting and unrelated +to lifecycle verification; no new skip was introduced. No required command remains blocked. Workerd +prints deliberate eviction-test exceptions and NDJSON content-type warnings without test failures. +All application suites passed; the first full integration attempt needed a longer terminal budget, +not a test fix. The successful retry took 760.32 s. Final review moved deadline/hold assertions +outside the cleanup provider stub so intentional provider-error catches cannot swallow assertions on +failed attempts. The entire integration suite was then rerun successfully against that final test +version (647.39 s); no source/test edits followed that pass before the initial PR handoff. + +Additional validation: + +- `npm test -w @open-inspect/control-plane -- src/sandbox/lifecycle/alarm-effects.test.ts --maxWorkers=1`: + exit 0, 1 file / 28 tests. +- `npm run test:integration -w @open-inspect/control-plane -- sandbox-vm-reconciliation sandbox-state-retention --maxWorkers=1`: + first exit 1 (3 new VM tests failed because fixture initialization retained a recent spawn + timestamp, correctly invoking the existing spawn-throttle gate; 35 retention tests passed). The + fixture resets its predecessor timestamp before the actual foreground reservation. Rerun exit 0, 2 + files / 38 tests. No production change or baseline suite failure was involved. +- `npx eslint eslint.config.js scripts/lint-sandbox-boundaries.test.mjs packages/control-plane/test/integration/sandbox-state-retention.test.ts packages/control-plane/test/integration/sandbox-vm-reconciliation.test.ts`: + exit 0; checks the edited configuration/scripts/integration tests outside the package's + `eslint src/` selection. All four typecheck configurations were also rerun after final assertion + refinement. + +## PR Review Follow-up + +The test-only follow-up to `3b9e7a0` uses `ModalVmStartupError("unknown", ...)`, settles the +original foreground create inside the assertions, and checks persisted state afterward. Refusal +cases change the reservation timestamp or replace its pending reference with another session's +pending reference; foreground completion must not mutate or publish. Success performs a second +lookup, not a second create, and preserves early readiness and conservative lifetime. Only the +still-live original instance can subsequently mint terminal access; the reconstructed graph cannot +recover that key. Named VM-resolution task selection no longer constrains unrelated background work. + +Rejected cleanup asserts every explicit handle, destructive intent, reason and bounded signal +outside the provider stub. A controlled `Date.now()` reaches each persisted deadline; the test +verifies the real host alarm, consumes it before each simulated platform callback, and verifies +replacement host arming after failure. Success retains the pre-I/O retry as designed; its final due +no-op delivery drains both host and persisted deadlines without stopping again. This models host +delivery semantics, not automatic Workerd clock advancement or live-provider timing. + +Validation uses the existing focused commands rather than repeating full application sweeps or +bundles for test/documentation-only changes. An initial new assertion incorrectly equated foreground +launch-config and narrower bridge lookup shapes; it was corrected to pin their common identity and +timeout fields, without changing production behavior. + +| Follow-up Command | Result | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------ | +| `npm run build -w @open-inspect/shared` | Passed | +| `npm run test:integration -w @open-inspect/control-plane -- sandbox-vm-reconciliation sandbox-state-retention --maxWorkers=1` | 2 files / 38 tests passed | +| `npm test -w @open-inspect/control-plane -- src/sandbox/lifecycle/vm-resolve.test.ts src/sandbox/lifecycle/rejected-allocation.test.ts src/session/alarm/scheduler.test.ts --maxWorkers=1` | 3 files / 71 tests passed | +| `npm run typecheck -w @open-inspect/control-plane` | All four configurations passed | +| `npx eslint packages/control-plane/test/integration/sandbox-vm-reconciliation.test.ts packages/control-plane/test/integration/sandbox-state-retention.test.ts` | Passed | +| `npm run format:check` | Whole repository passed | +| `git diff --check` | Passed | + +## Related Work + +At the audited checkout, COL-130/151/155/156/159 remain Backlog; existing socket names, boot-phase +helpers, shutdown phase projection, construction-policy defaults and old contracts are retained, not +opportunistically renamed/redesigned/deleted. COL-161 and COL-238 remain In Progress; spawn-time +context injection and PR #2141's heartbeat confirmation are not imported. COL-150's pure alarm +policy is reused. These issues are not closed, reparented or otherwise absorbed by this +verification. + +Separately landed work since the research baseline includes Modal VM Docker-preparation/error/tunnel +lookup fixes (#2136/#2138/#2139), Modal save/termination handling (#2146), held-save alarm retries +(`8ed1aea`, #2152), model-catalog additions (#2147), identity/team authorization/secrets/grants and +repository-scoped installation credentials (#2179/#2180/#2181), and COL-226 restore credentials +(`f1cf069`, #2178). Their behavior is preserved and distinguished from extraction; +shared/web/backend contracts are not changed in T7. Recheck concurrent work at merge, not by +restoring the old baseline. + +## Separate Gaps and Evidence Limits + +Green characterization tests preserve current behavior; they do not endorse it as safe. Follow-up +behavior work should use scoped authorization and regressions, not silently extend this refactor: + +- **Unmanaged ownership await and absent target:** the new inactivity reproducer replaces the row + while `requestShutdown()` waits. On `unmanaged`, old work retires successor access/status and an + absent captured handle falls back to the successor handle with the old timestamp. Follow up with + immediate generation/ownership revalidation and an explicit-target stop contract preserving + absence. +- **Connect-timeout publication:** the new stop-gate reproducer installs a ready replacement. Old + completion persists its error on that row and broadcasts failure despite the row staying ready. + Follow up with generation-scoped failure persistence/publication, not a universal teardown guard. +- **Fresh/restore access:** the existing real-SQL encryption/replacement reproducer shows unguarded + per-artifact writes landing on a successor. Separately design generation-conditional artifact + writes. +- **Prior retirement:** captured prior-handle stop followed by a replaced row can clear the current + singleton handle. This remains distinct from matching rejected-cleanup completion; follow up with + conditional clearing under the existing confirmation-required/best-effort policy. +- **Inherited partial boundaries:** access-clear/notify exceptions can skip detach; saved-resume + secret-read failure before commit holds successful provider recovery; bridge access can commit + before held lifecycle acceptance; pending sandbox/shutdown handle registration uses separate + writes. Existing characterization and fault traces are not atomicity guarantees. Address each + separately. +- **Scheduling/attachment:** inactivity warnings retain their full grace interval rather than the + healthy-path heartbeat cap; final attachment identity recheck does not check status. Existing + policy characterization and COL-238 remain separate work, not universal liveness guarantees. + +Remaining evidence limits: no single assembled real-storage old-auth/second-reservation/hash race; +no actual base-image retry combined with an outstanding old bridge across hash publication; no full +Node-host lifecycle/alarm transport test beyond current runtime/storage conformance; no exhaustive +Workerd boot-budget/heartbeat-to-queue matrix. Existing complementary assembled/storage tests remain +useful but are not stronger combined-race or backend guarantees. The new VM tests use substituted +socket transport and public attachment/readiness operations, not the full upgrade/authenticator +path. + +## Release and Rollback + +Normal review and merge remain required; T7 is not yet integrated and this report does not close the +parent before merge. No schema migration, feature flag or deployment is required by extraction. No +provider canary, exactly-once execution, remote cancellation or guaranteed recovery is claimed. + +If maintainers release, inspect existing launch/ready latency, breakers, access, rejected-cleanup +retries, holds and shared alarms. Provider-backed canaries are a release decision, not evidence from +Workerd substitutes. Roll back a coherent reviewed increment with dependent increments and +separately landed fixes accounted for; do not use an arbitrary old binary or destructive history +reset. diff --git a/eslint.config.js b/eslint.config.js index 83a7a31725..8c9db139d8 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -17,6 +17,10 @@ const sandboxImplementationImports = [ message: "Only session composition constructs the lifecycle manager. Consumers depend on focused lifecycle ports.", }, + { + regex: "(?:^|/)lifecycle/launch-context(?:\\.[cm]?[jt]sx?)?$", + message: "Launch inputs are internal to lifecycle composition. Consumers use lifecycle ports.", + }, { regex: "(?:^|/)lifecycle/sandbox-access(?:\\.[cm]?[jt]sx?)?$", message: @@ -37,6 +41,16 @@ const sandboxImplementationImports = [ message: "Bounded provider-stop mechanics are internal to lifecycle. Consumers use lifecycle ports.", }, + { + regex: "(?:^|/)lifecycle/startup-errors(?:\\.[cm]?[jt]sx?)?$", + message: + "Startup abandonment errors are internal to lifecycle. Consumers use lifecycle outcomes.", + }, + { + regex: "(?:^|/)lifecycle/watchdog-effects(?:\\.[cm]?[jt]sx?)?$", + message: + "Watchdog effects are internal to the lifecycle manager. Consumers use lifecycle ports.", + }, ]; export default tseslint.config( diff --git a/packages/control-plane/README.md b/packages/control-plane/README.md index f2c3967a52..90a6e01389 100644 --- a/packages/control-plane/README.md +++ b/packages/control-plane/README.md @@ -500,15 +500,14 @@ The system uses two types of GitHub tokens: | GitHub App Token | Clone, fetch, push | Brokered to credential helper | All repos where App is installed | | User OAuth Token | Create PRs | Server-only | User's accessible repos | -Fresh and prebuilt-image sandboxes do not receive a long-lived `GITHUB_TOKEN`, `GITHUB_APP_TOKEN`, +Session sandboxes, including snapshot restores, do not receive `GITHUB_TOKEN`, `GITHUB_APP_TOKEN`, or `VCS_CLONE_TOKEN` for normal git operations. Git invokes the sandbox credential helper, which calls `/sessions/:id/scm-credentials` with the sandbox auth token and receives short-lived -credentials on demand. Legacy snapshots and one-shot image builds may still receive env-token -fallbacks for compatibility. The helper preserves the existing installation-wide model by serving +credentials on demand. The helper preserves the existing installation-wide model by serving credentials for HTTPS git requests to the configured SCM host, including setup/start hooks that clone auxiliary private repos. This avoids stale embedded credentials in long-running sessions and -Daytona persistent resumes; Modal snapshot restores still mint a fresh fallback token during -restore. +persistent resumes. One-shot image builds still receive `VCS_CLONE_TOKEN` because they have no +session to broker through. If a `create-pr` request is triggered by a participant without a user OAuth token (for example, Slack-created or Google-login sessions), the sandbox can still push the branch with brokered GitHub diff --git a/packages/control-plane/src/auth/github-app.cache.test.ts b/packages/control-plane/src/auth/github-app.cache.test.ts new file mode 100644 index 0000000000..3eac46cb74 --- /dev/null +++ b/packages/control-plane/src/auth/github-app.cache.test.ts @@ -0,0 +1,332 @@ +import type { CacheStore } from "@open-inspect/shared/cache-store"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { GitHubAppConfig, TokenScope } from "./github-app"; +import type * as GitHubAppAuth from "./github-app"; + +class FakeCacheStore implements CacheStore { + readonly entries = new Map(); + + async get(key: string): Promise; + async get(key: string, type: "json"): Promise; + async get(key: string, type?: "json"): Promise { + const value = this.entries.get(key) ?? null; + return type === "json" && value !== null ? JSON.parse(value) : value; + } + + async put(key: string, value: string): Promise { + this.entries.set(key, value); + } + + async delete(key: string): Promise { + this.entries.delete(key); + } +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +let privateKey: string; +let auth: typeof GitHubAppAuth; + +beforeAll(async () => { + const pair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"] + ); + if (!("privateKey" in pair)) throw new Error("Expected an RSA key pair"); + const exported = await crypto.subtle.exportKey("pkcs8", pair.privateKey); + if (!(exported instanceof ArrayBuffer)) throw new Error("Expected a PKCS#8 byte buffer"); + privateKey = `-----BEGIN PRIVATE KEY-----\n${btoa(String.fromCharCode(...new Uint8Array(exported)))}\n-----END PRIVATE KEY-----`; +}); + +beforeEach(async () => { + vi.resetModules(); + auth = await import("./github-app"); +}); + +afterEach(() => vi.restoreAllMocks()); + +function config(): GitHubAppConfig { + return { appId: "cache-test-app", installationId: "cache-test-installation", privateKey }; +} + +/** Config whose key cannot sign, proving a request was served without minting. */ +function cacheOnlyConfig(): GitHubAppConfig { + return { ...config(), privateKey: "invalid-key-must-not-be-used" }; +} + +function tokenEntry(token: string) { + return JSON.stringify({ + token, + cachedAtEpochMs: Date.now(), + expiresAtEpochMs: Date.now() + 60 * 60 * 1000, + }); +} + +function mintResponse(token = "fresh-token") { + return Response.json({ token, expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString() }); +} + +describe("installation token scopes", () => { + it("shares a cache key across permuted or duplicated ids and separates every other scope", async () => { + const key = (scope: TokenScope, app = config()) => + auth.getInstallationTokenCacheKey(app, scope); + const canonical = await key({ kind: "repositories", repositoryIds: [30, 2, 30] }); + expect(await key({ kind: "repositories", repositoryIds: [2, 30] })).toBe(canonical); + + const others = await Promise.all([ + key({ kind: "repositories", repositoryIds: [2] }), + key({ kind: "repositories", repositoryIds: [2, 30, 99] }), + key({ kind: "all" }), + key({ kind: "repositories", repositoryIds: [2, 30] }, { ...config(), appId: "other-app" }), + key( + { kind: "repositories", repositoryIds: [2, 30] }, + { ...config(), installationId: "other-installation" } + ), + ]); + expect(new Set([canonical, ...others]).size).toBe(others.length + 1); + }); + + it("mints repository scopes with their canonical ids and installation scopes without a body", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => mintResponse()); + await auth.getCachedInstallationToken(config(), undefined, { + scope: { kind: "repositories", repositoryIds: [30, 2, 30] }, + }); + await auth.getCachedInstallationToken(config(), undefined, { scope: { kind: "all" } }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const [[, scoped], [, all]] = fetchMock.mock.calls; + expect(new Headers(scoped?.headers).get("Content-Type")).toBe("application/json"); + expect(JSON.parse(String(scoped?.body))).toEqual({ repository_ids: [2, 30] }); + expect(all?.body).toBeUndefined(); + }); + + it("refuses an empty repository scope before reading the cache or minting", async () => { + const cacheStore = new FakeCacheStore(); + const get = vi.spyOn(cacheStore, "get"); + const fetchMock = vi.spyOn(globalThis, "fetch"); + await expect( + auth.getCachedInstallationToken( + config(), + { cacheStore }, + { scope: { kind: "repositories", repositoryIds: [] } } + ) + ).rejects.toThrow("no repositories"); + expect(get).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("installation token memory cache", () => { + it("evicts the least recently used scope at the memory bound", async () => { + const cacheStore = new FakeCacheStore(); + const app = cacheOnlyConfig(); + const scope = (id: number): TokenScope => ({ kind: "repositories", repositoryIds: [id] }); + const read = (id: number) => + auth.getCachedInstallationToken(app, { cacheStore }, { scope: scope(id) }); + const max = auth.INSTALLATION_TOKEN_MEMORY_CACHE_MAX_ENTRIES; + for (let id = 1; id <= max + 1; id++) { + await cacheStore.put( + await auth.getInstallationTokenCacheKey(app, scope(id)), + tokenEntry(`token-${id}`) + ); + await read(id); + // Touch the first scope so the second becomes least recently used. + if (id === max) await read(1); + } + for (const id of [1, 2]) { + await cacheStore.put( + await auth.getInstallationTokenCacheKey(app, scope(id)), + tokenEntry("replaced") + ); + } + + expect(await read(1)).toBe("token-1"); + expect(await read(2)).toBe("replaced"); + }); + + it("serves memory hits until the cache max age, then rereads the persistent cache", async () => { + const cacheStore = new FakeCacheStore(); + const app = cacheOnlyConfig(); + const scope: TokenScope = { kind: "all" }; + const key = await auth.getInstallationTokenCacheKey(app, scope); + let now = Date.now(); + vi.spyOn(Date, "now").mockImplementation(() => now); + await cacheStore.put(key, tokenEntry("old-token")); + expect(await auth.getCachedInstallationToken(app, { cacheStore }, { scope })).toBe("old-token"); + await cacheStore.put(key, tokenEntry("new-token")); + expect(await auth.getCachedInstallationToken(app, { cacheStore }, { scope })).toBe("old-token"); + now += auth.INSTALLATION_TOKEN_CACHE_MAX_AGE_MS; + await cacheStore.put(key, tokenEntry("new-token")); + expect(await auth.getCachedInstallationToken(app, { cacheStore }, { scope })).toBe("new-token"); + }); + + it("drops the memory and persistent entries on invalidation", async () => { + const cacheStore = new FakeCacheStore(); + const app = cacheOnlyConfig(); + const scope: TokenScope = { kind: "all" }; + const key = await auth.getInstallationTokenCacheKey(app, scope); + await cacheStore.put(key, tokenEntry("rejected-token")); + expect(await auth.getCachedInstallationToken(app, { cacheStore }, { scope })).toBe( + "rejected-token" + ); + + await auth.invalidateInstallationTokenCache({ cacheStore }, key); + expect(cacheStore.entries.has(key)).toBe(false); + await cacheStore.put(key, tokenEntry("replacement-token")); + expect(await auth.getCachedInstallationToken(app, { cacheStore }, { scope })).toBe( + "replacement-token" + ); + }); +}); + +describe("installation token refresh single-flight", () => { + it("starts a fresh mint when a forced caller arrives after a persistent hit was selected", async () => { + const cacheStore = new FakeCacheStore(); + const app = config(); + const scope: TokenScope = { kind: "all" }; + await cacheStore.put( + await auth.getInstallationTokenCacheKey(app, scope), + tokenEntry("cached-token") + ); + const selected = deferred(); + const releaseCleanup = deferred(); + const originalFinally = Promise.prototype.finally; + vi.spyOn(Promise.prototype, "finally").mockImplementationOnce(function ( + this: Promise, + cleanup + ) { + return originalFinally.call(this, async () => { + selected.resolve(); + await releaseCleanup.promise; + cleanup?.(); + }); + }); + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => mintResponse()); + const first = auth.getCachedInstallationToken(app, { cacheStore }, { scope }); + await selected.promise; + const forced = auth.getCachedInstallationToken( + app, + { cacheStore }, + { scope, forceRefresh: true } + ); + await Promise.resolve(); + releaseCleanup.resolve(); + expect(await first).toBe("cached-token"); + expect(await forced).toBe("fresh-token"); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it("joins overlapping forced and ordinary refreshes for the same scope", async () => { + const started = deferred(); + const release = deferred(); + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + started.resolve(); + await release.promise; + return mintResponse(); + }); + const scope: TokenScope = { kind: "all" }; + const first = auth.getCachedInstallationToken(config(), undefined, { + scope, + forceRefresh: true, + }); + await started.promise; + const second = auth.getCachedInstallationToken(config(), undefined, { + scope, + forceRefresh: true, + }); + const third = auth.getCachedInstallationToken(config(), undefined, { scope }); + release.resolve(); + expect(await Promise.all([first, second, third])).toEqual([ + "fresh-token", + "fresh-token", + "fresh-token", + ]); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it("keeps an in-flight mint registered when the scope is invalidated", async () => { + const cacheStore = new FakeCacheStore(); + const started = deferred(); + const release = deferred(); + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + started.resolve(); + await release.promise; + return mintResponse(); + }); + const app = config(); + const scope: TokenScope = { kind: "all" }; + const first = auth.getCachedInstallationToken( + app, + { cacheStore }, + { scope, forceRefresh: true } + ); + await started.promise; + await auth.invalidateInstallationTokenCache( + { cacheStore }, + await auth.getInstallationTokenCacheKey(app, scope) + ); + const second = auth.getCachedInstallationToken( + app, + { cacheStore }, + { scope, forceRefresh: true } + ); + release.resolve(); + expect(await Promise.all([first, second])).toEqual(["fresh-token", "fresh-token"]); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it("discards a pending persistent-cache read invalidated before it completes", async () => { + const cacheStore = new FakeCacheStore(); + const readStarted = deferred(); + const releaseRead = deferred(); + vi.spyOn(cacheStore, "get").mockImplementation(async () => { + readStarted.resolve(); + await releaseRead.promise; + return JSON.parse(tokenEntry("rejected-token")); + }); + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => mintResponse()); + const app = config(); + const scope: TokenScope = { kind: "all" }; + const first = auth.getCachedInstallationToken(app, { cacheStore }, { scope }); + await readStarted.promise; + await auth.invalidateInstallationTokenCache( + { cacheStore }, + await auth.getInstallationTokenCacheKey(app, scope) + ); + const forced = auth.getCachedInstallationToken( + app, + { cacheStore }, + { scope, forceRefresh: true } + ); + releaseRead.resolve(); + expect(await Promise.all([first, forced])).toEqual(["fresh-token", "fresh-token"]); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it("cleans up a failed flight so a later request can retry", async () => { + const scope: TokenScope = { kind: "all" }; + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockRejectedValueOnce(new Error("network failure")) + .mockResolvedValueOnce(mintResponse()); + await expect( + auth.getCachedInstallationToken(config(), undefined, { scope, forceRefresh: true }) + ).rejects.toThrow("network failure"); + expect(await auth.getCachedInstallationToken(config(), undefined, { scope })).toBe( + "fresh-token" + ); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/control-plane/src/auth/github-app.test.ts b/packages/control-plane/src/auth/github-app.test.ts index 27766cd467..d6c6c26e4d 100644 --- a/packages/control-plane/src/auth/github-app.test.ts +++ b/packages/control-plane/src/auth/github-app.test.ts @@ -67,7 +67,7 @@ async function cacheInstallationToken( config: ReturnType ): Promise { await cacheStore.put( - `github:installation-token:v1:${config.appId}:${config.installationId}`, + `github:installation-token:v2:${config.appId}:${config.installationId}:all`, JSON.stringify({ token: "cached-token", expiresAtEpochMs: @@ -193,7 +193,7 @@ describe("github-app utilities", () => { }; await cacheStore.put( - `github:installation-token:v1:${config.appId}:${config.installationId}`, + `github:installation-token:v2:${config.appId}:${config.installationId}:all`, JSON.stringify({ token: "token-from-kv", expiresAtEpochMs: @@ -202,9 +202,11 @@ describe("github-app utilities", () => { }) ); - const token = await getCachedInstallationToken(config, { - cacheStore, - }); + const token = await getCachedInstallationToken( + config, + { cacheStore }, + { scope: { kind: "all" } } + ); expect(token).toBe("token-from-kv"); expect(fetchMock).not.toHaveBeenCalled(); @@ -227,7 +229,7 @@ describe("github-app utilities", () => { installationId: "installation-malformed-cache", }; await cacheStore.put( - `github:installation-token:v1:${config.appId}:${config.installationId}`, + `github:installation-token:v2:${config.appId}:${config.installationId}:all`, JSON.stringify({ token: 42, expiresAtEpochMs: Date.parse(expiresAt), @@ -235,7 +237,9 @@ describe("github-app utilities", () => { }) ); - await expect(getCachedInstallationToken(config, { cacheStore })).resolves.toBe("fresh-token"); + await expect( + getCachedInstallationToken(config, { cacheStore }, { scope: { kind: "all" } }) + ).resolves.toBe("fresh-token"); expect(fetchMock).toHaveBeenCalledOnce(); }); }); @@ -258,7 +262,7 @@ describe("github-app utilities", () => { const expiresAtEpochMs = Date.now() + INSTALLATION_TOKEN_CACHE_MAX_AGE_MS + INSTALLATION_TOKEN_MIN_REMAINING_MS; await cacheStore.put( - `github:installation-token:v1:${config.appId}:${config.installationId}`, + `github:installation-token:v2:${config.appId}:${config.installationId}:all`, JSON.stringify({ token: "tok-with-expiry", expiresAtEpochMs, @@ -266,7 +270,11 @@ describe("github-app utilities", () => { }) ); - const result = await getCachedInstallationTokenWithExpiry(config, { cacheStore }); + const result = await getCachedInstallationTokenWithExpiry( + config, + { cacheStore }, + { scope: { kind: "all" } } + ); expect(result).toEqual({ token: "tok-with-expiry", expiresAtEpochMs }); expect(fetchMock).not.toHaveBeenCalled(); @@ -282,7 +290,7 @@ describe("github-app utilities", () => { }; await cacheStore.put( - `github:installation-token:v1:${config.appId}:${config.installationId}`, + `github:installation-token:v2:${config.appId}:${config.installationId}:all`, JSON.stringify({ token: "shared-token", expiresAtEpochMs: @@ -291,8 +299,16 @@ describe("github-app utilities", () => { }) ); - const plain = await getCachedInstallationToken(config, { cacheStore }); - const withExpiry = await getCachedInstallationTokenWithExpiry(config, { cacheStore }); + const plain = await getCachedInstallationToken( + config, + { cacheStore }, + { scope: { kind: "all" } } + ); + const withExpiry = await getCachedInstallationTokenWithExpiry( + config, + { cacheStore }, + { scope: { kind: "all" } } + ); expect(withExpiry.token).toBe(plain); }); @@ -314,7 +330,7 @@ describe("github-app utilities", () => { installationId: "installation-refresh-valid", }, undefined, - { forceRefresh: true } + { scope: { kind: "all" }, forceRefresh: true } ); expect(result).toEqual({ token: "fresh-token", expiresAtEpochMs: Date.parse(expiresAt) }); @@ -337,7 +353,7 @@ describe("github-app utilities", () => { installationId: "installation-escaped-key", }, undefined, - { forceRefresh: true } + { scope: { kind: "all" }, forceRefresh: true } ); expect(result.token).toBe("escaped-key-token"); @@ -358,7 +374,7 @@ describe("github-app utilities", () => { installationId: "installation-refresh-invalid", }, undefined, - { forceRefresh: true } + { scope: { kind: "all" }, forceRefresh: true } ) ).rejects.toThrow("Failed to get installation token: invalid response"); }); @@ -376,7 +392,7 @@ describe("github-app utilities", () => { installationId: "installation-refresh-invalid-json", }, undefined, - { forceRefresh: true } + { scope: { kind: "all" }, forceRefresh: true } ) ).rejects.toThrow("Failed to get installation token: invalid response"); }); @@ -398,7 +414,7 @@ describe("github-app utilities", () => { installationId: "installation-refresh-invalid-expiry", }, undefined, - { forceRefresh: true } + { scope: { kind: "all" }, forceRefresh: true } ) ).rejects.toThrow("Failed to get installation token: invalid response"); }); diff --git a/packages/control-plane/src/auth/github-app.ts b/packages/control-plane/src/auth/github-app.ts index 75f825421c..2aec8ba244 100644 --- a/packages/control-plane/src/auth/github-app.ts +++ b/packages/control-plane/src/auth/github-app.ts @@ -12,7 +12,12 @@ import type { InstallationRepository } from "@open-inspect/shared/types/repository-catalog"; import { DEFAULT_APP_NAME } from "@open-inspect/shared/app-name"; import type { CacheStore } from "@open-inspect/shared/cache-store"; +import { sha256Hex } from "@open-inspect/shared/service-auth"; import { z } from "zod"; +import { + repositoryCredentialScope, + type CredentialScope, +} from "../source-control/credential-scope"; import { base64UrlEncode } from "./encoding"; @@ -25,10 +30,20 @@ export const INSTALLATION_TOKEN_CACHE_MAX_AGE_MS = 50 * 60 * 1000; /** Require at least this much remaining lifetime before using a cached token (ms). */ export const INSTALLATION_TOKEN_MIN_REMAINING_MS = 5 * 60 * 1000; +/** Maximum distinct token scopes retained in the process cache. */ +export const INSTALLATION_TOKEN_MEMORY_CACHE_MAX_ENTRIES = 128; + /** Upper bound for KV cache TTL (seconds). */ const INSTALLATION_TOKEN_CACHE_MAX_TTL_SECONDS = 3600; -const INSTALLATION_TOKEN_CACHE_KEY_PREFIX = "github:installation-token:v1"; +const INSTALLATION_TOKEN_CACHE_KEY_PREFIX = "github:installation-token:v2"; + +export type TokenScope = CredentialScope; + +interface InstallationTokenOptions { + scope: TokenScope; + forceRefresh?: boolean; +} interface InstallationTokenCacheBindings { cacheStore?: CacheStore; @@ -60,7 +75,10 @@ function createHttpError(message: string, status: number): GitHubHttpError { } const installationTokenMemoryCache = new Map(); -const installationTokenRefreshInFlight = new Map>(); +const installationTokenRefreshInFlight = new Map< + string, + { promise: Promise; options: { forceRefresh: boolean } } +>(); const importedPrivateKeyCache = new Map>(); /** Fetch with an AbortController timeout. */ @@ -249,7 +267,8 @@ async function generateAppJwt(appId: string, privateKey: string): Promise { const url = `https://api.github.com/app/installations/${installationId}/access_tokens`; @@ -260,7 +279,11 @@ async function getInstallationTokenWithMetadata( Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", "User-Agent": userAgent, + ...(scope.kind === "repositories" ? { "Content-Type": "application/json" } : {}), }, + ...(scope.kind === "repositories" + ? { body: JSON.stringify({ repository_ids: scope.repositoryIds }) } + : {}), }); if (!response.ok) { @@ -287,8 +310,16 @@ async function getInstallationTokenWithMetadata( return parsed.data; } -function getInstallationTokenCacheKey(config: GitHubAppConfig): string { - return `${INSTALLATION_TOKEN_CACHE_KEY_PREFIX}:${config.appId}:${config.installationId}`; +export async function getInstallationTokenCacheKey( + config: GitHubAppConfig, + scope: TokenScope +): Promise { + let scopeHash = "all"; + if (scope.kind === "repositories") { + const ids = repositoryCredentialScope(scope.repositoryIds).repositoryIds; + scopeHash = await sha256Hex(JSON.stringify(ids)); + } + return `${INSTALLATION_TOKEN_CACHE_KEY_PREFIX}:${config.appId}:${config.installationId}:${scopeHash}`; } function isTokenUsable(cached: CachedInstallationToken, nowEpochMs = Date.now()): boolean { @@ -299,6 +330,20 @@ function isTokenUsable(cached: CachedInstallationToken, nowEpochMs = Date.now()) return nowEpochMs < cached.expiresAtEpochMs - INSTALLATION_TOKEN_MIN_REMAINING_MS; } +function cacheInstallationTokenInMemory(cacheKey: string, cached: CachedInstallationToken): void { + const nowEpochMs = Date.now(); + for (const [key, token] of installationTokenMemoryCache) { + if (!isTokenUsable(token, nowEpochMs)) installationTokenMemoryCache.delete(key); + } + installationTokenMemoryCache.delete(cacheKey); + if (!isTokenUsable(cached, nowEpochMs)) return; + installationTokenMemoryCache.set(cacheKey, cached); + if (installationTokenMemoryCache.size > INSTALLATION_TOKEN_MEMORY_CACHE_MAX_ENTRIES) { + const oldestKey = installationTokenMemoryCache.keys().next().value; + if (oldestKey !== undefined) installationTokenMemoryCache.delete(oldestKey); + } +} + async function readInstallationTokenFromCache( env: InstallationTokenCacheBindings | undefined, cacheKey: string @@ -345,12 +390,13 @@ async function writeInstallationTokenToCache( } } -async function invalidateInstallationTokenCache( +export async function invalidateInstallationTokenCache( env: InstallationTokenCacheBindings | undefined, cacheKey: string ): Promise { installationTokenMemoryCache.delete(cacheKey); - installationTokenRefreshInFlight.delete(cacheKey); + const pending = installationTokenRefreshInFlight.get(cacheKey); + if (pending) pending.options.forceRefresh = true; if (!env?.cacheStore) { return; @@ -366,14 +412,16 @@ async function invalidateInstallationTokenCache( async function refreshInstallationToken( config: GitHubAppConfig, env: InstallationTokenCacheBindings | undefined, - cacheKey: string + cacheKey: string, + scope: TokenScope ): Promise { const nowEpochMs = Date.now(); const jwt = await generateAppJwt(config.appId, config.privateKey); const tokenData = await getInstallationTokenWithMetadata( jwt, config.installationId, - resolveUserAgent(env) + resolveUserAgent(env), + scope ); const cached: CachedInstallationToken = { token: tokenData.token, @@ -381,41 +429,64 @@ async function refreshInstallationToken( cachedAtEpochMs: nowEpochMs, }; - installationTokenMemoryCache.set(cacheKey, cached); + cacheInstallationTokenInMemory(cacheKey, cached); await writeInstallationTokenToCache(env, cacheKey, cached); return cached; } async function getOrRefreshCachedInstallationToken( config: GitHubAppConfig, - env?: InstallationTokenCacheBindings, - options?: { forceRefresh?: boolean } + env: InstallationTokenCacheBindings | undefined, + options: InstallationTokenOptions ): Promise { - const cacheKey = getInstallationTokenCacheKey(config); - const forceRefresh = options?.forceRefresh ?? false; + const scope: TokenScope = + options.scope.kind === "all" + ? options.scope + : repositoryCredentialScope(options.scope.repositoryIds); + const cacheKey = await getInstallationTokenCacheKey(config, scope); + const forceRefresh = options.forceRefresh ?? false; + + const pending = installationTokenRefreshInFlight.get(cacheKey); + if (pending) { + if (forceRefresh) pending.options.forceRefresh = true; + return pending.promise; + } if (!forceRefresh) { const memoryCached = installationTokenMemoryCache.get(cacheKey); if (memoryCached && isTokenUsable(memoryCached)) { + // Re-insert to mark most recently used; expired entries are pruned on insert. + installationTokenMemoryCache.delete(cacheKey); + installationTokenMemoryCache.set(cacheKey, memoryCached); return memoryCached; } + installationTokenMemoryCache.delete(cacheKey); + } - const persistentCached = await readInstallationTokenFromCache(env, cacheKey); - if (persistentCached && isTokenUsable(persistentCached)) { - installationTokenMemoryCache.set(cacheKey, persistentCached); - return persistentCached; + const refreshOptions = { forceRefresh }; + const refreshPromise = (async () => { + if (!refreshOptions.forceRefresh) { + const persistentCached = await readInstallationTokenFromCache(env, cacheKey); + // Invalidation or a forced caller can supersede a pending cache read. + if (!refreshOptions.forceRefresh && persistentCached && isTokenUsable(persistentCached)) { + cacheInstallationTokenInMemory(cacheKey, persistentCached); + // Forced callers must not join work that has already selected a cache hit. + if (installationTokenRefreshInFlight.get(cacheKey)?.options === refreshOptions) { + installationTokenRefreshInFlight.delete(cacheKey); + } + return persistentCached; + } } - - const inFlight = installationTokenRefreshInFlight.get(cacheKey); - if (inFlight) { - return inFlight; + return refreshInstallationToken(config, env, cacheKey, scope); + })().finally(() => { + if (installationTokenRefreshInFlight.get(cacheKey)?.promise === refreshPromise) { + installationTokenRefreshInFlight.delete(cacheKey); } - } - - const refreshPromise = refreshInstallationToken(config, env, cacheKey).finally(() => { - installationTokenRefreshInFlight.delete(cacheKey); }); - installationTokenRefreshInFlight.set(cacheKey, refreshPromise); + installationTokenRefreshInFlight.set(cacheKey, { + promise: refreshPromise, + options: refreshOptions, + }); return refreshPromise; } @@ -425,8 +496,8 @@ async function getOrRefreshCachedInstallationToken( */ export async function getCachedInstallationToken( config: GitHubAppConfig, - env?: InstallationTokenCacheBindings, - options?: { forceRefresh?: boolean } + env: InstallationTokenCacheBindings | undefined, + options: InstallationTokenOptions ): Promise { const cached = await getOrRefreshCachedInstallationToken(config, env, options); return cached.token; @@ -440,8 +511,8 @@ export async function getCachedInstallationToken( */ export async function getCachedInstallationTokenWithExpiry( config: GitHubAppConfig, - env?: InstallationTokenCacheBindings, - options?: { forceRefresh?: boolean } + env: InstallationTokenCacheBindings | undefined, + options: InstallationTokenOptions ): Promise<{ token: string; expiresAtEpochMs: number }> { const cached = await getOrRefreshCachedInstallationToken(config, env, options); return { token: cached.token, expiresAtEpochMs: cached.expiresAtEpochMs }; @@ -464,7 +535,8 @@ export async function listInstallationRepositories( env?: InstallationTokenCacheBindings ): Promise<{ repos: InstallationRepository[]; timing: ListReposTiming }> { const tokenStart = performance.now(); - let token = await getCachedInstallationToken(config, env); + const scope: TokenScope = { kind: "all" }; + let token = await getCachedInstallationToken(config, env, { scope }); const tokenGenerationMs = performance.now() - tokenStart; const perPage = 100; @@ -526,8 +598,8 @@ export async function listInstallationRepositories( throw error; } - await invalidateInstallationTokenCache(env, getInstallationTokenCacheKey(config)); - token = await getCachedInstallationToken(config, env, { forceRefresh: true }); + await invalidateInstallationTokenCache(env, await getInstallationTokenCacheKey(config, scope)); + token = await getCachedInstallationToken(config, env, { scope, forceRefresh: true }); headers.Authorization = `Bearer ${token}`; first = await fetchPage(1); } @@ -571,12 +643,13 @@ export async function getInstallationRepository( repo: string, env?: InstallationTokenCacheBindings ): Promise { - const cacheKey = getInstallationTokenCacheKey(config); + const scope: TokenScope = { kind: "all" }; + const cacheKey = await getInstallationTokenCacheKey(config, scope); let forceRefresh = false; let response!: Response; for (let attempt = 0; attempt < 2; attempt++) { - const token = await getCachedInstallationToken(config, env, { forceRefresh }); + const token = await getCachedInstallationToken(config, env, { scope, forceRefresh }); response = await fetchWithTimeout(`https://api.github.com/repos/${owner}/${repo}`, { headers: { Authorization: `Bearer ${token}`, @@ -631,7 +704,7 @@ export async function listRepositoryBranches( repo: string, env?: InstallationTokenCacheBindings ): Promise<{ name: string }[]> { - const token = await getCachedInstallationToken(config, env); + const token = await getCachedInstallationToken(config, env, { scope: { kind: "all" } }); const branches: { name: string }[] = []; let page = 1; diff --git a/packages/control-plane/src/authorization/owned-resource-admission.test.ts b/packages/control-plane/src/authorization/owned-resource-admission.test.ts new file mode 100644 index 0000000000..2f565f435d --- /dev/null +++ b/packages/control-plane/src/authorization/owned-resource-admission.test.ts @@ -0,0 +1,343 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PermissionId } from "@open-inspect/shared/rbac"; +import type { TeamRole } from "@open-inspect/shared/types/teams"; +import { createTestBackgroundTasks } from "../background-tasks.test-support"; +import { AutomationStore, type AutomationRow } from "../db/automation-store"; +import { EnvironmentStore, type EnvironmentRow } from "../db/environments"; +import { createRequestMetrics } from "../db/instrumented-sql-database"; +import type { RequestContext } from "../http/request-context"; +import { + authorizeEnvironmentTarget, + authorizeSessionTarget, +} from "../routes/session-target-authorization"; +import { admittedEnvironment, evaluateOwnedResourceAdmission } from "./owned-resource-admission"; + +// Full row validation is covered by D1 tests; admission consumes only ownership fields. +const environment = { id: "environment", owner_team_id: "team" } as EnvironmentRow; +const environmentRequirement = { kind: "environment", need: "manage", idParam: "id" } as const; + +function context(): RequestContext { + return { + db: { + prepare: () => { + throw new Error("Unexpected SQL query"); + }, + batch: async () => [], + }, + request_id: "owned-resource-admission", + trace_id: "owned-resource-admission", + metrics: createRequestMetrics(), + executionCtx: createTestBackgroundTasks(), + principal: { kind: "user", userId: "user" }, + authorization: { + userId: "user", + role: { id: "custom-role", key: null, name: "Custom" }, + permissions: ["environments.manage"], + suspendedAt: null, + }, + sessionMemberships: new Map([["team", "lead"]]), + }; +} + +describe("owned-resource admission outcomes", () => { + beforeEach(() => { + vi.spyOn(EnvironmentStore.prototype, "getById").mockResolvedValue(environment); + }); + afterEach(() => vi.restoreAllMocks()); + + it("hides invisible environments like missing ones, returning denial audit context", async () => { + const ctx = context(); + ctx.sessionMemberships = new Map(); + const hidden = await evaluateOwnedResourceAdmission( + environmentRequirement, + { id: "resource" }, + ctx + ); + vi.mocked(EnvironmentStore.prototype.getById).mockResolvedValue(null); + const missing = await evaluateOwnedResourceAdmission( + environmentRequirement, + { id: "id" }, + context() + ); + const { admission, ...response } = hidden as Extract; + // Only the audit context differs; the HTTP-visible outcome is identical. + expect(admission?.environment).toBe(environment); + expect(missing).toEqual(response); + expect(response).toEqual({ + kind: "denied", + status: 404, + response: { error: "Environment not found" }, + reasonCode: "environment_not_visible", + reason: "Environment not found", + }); + // Evaluation never writes request state; route admission owns that. + expect(ctx.environmentAdmission).toBeUndefined(); + }); + + it.each([ + { requirement: environmentRequirement, permission: "environments.manage" }, + { + requirement: { ...environmentRequirement, need: "use" }, + permission: "environments.use", + }, + ] as const)("allows $permission without read permission", async ({ requirement, permission }) => { + const ctx = context(); + ctx.authorization!.permissions = [permission]; + await expect( + evaluateOwnedResourceAdmission(requirement, { id: "resource" }, ctx) + ).resolves.toEqual({ + kind: "allowed", + effectivePermission: permission, + admission: { environment, viewer: expect.objectContaining({ kind: "user" }) }, + }); + }); + + it("maps visible read/action denials to 403 and retains loaded audit context", async () => { + for (const read of [true, false]) { + const ctx = context(); + ctx.sessionMemberships = new Map([["team", "member"]]); + const target = read + ? { ...environmentRequirement, need: "read" as const } + : environmentRequirement; + const reason = read ? "missing_permission" : "not_owner_or_lead"; + expect(await evaluateOwnedResourceAdmission(target, { id: "resource" }, ctx)).toEqual({ + kind: "denied", + status: 403, + reasonCode: reason, + reason: "Forbidden", + response: { error: "Forbidden", code: "environment_action_denied", reason_code: reason }, + failedPermission: `environments.${read ? "read" : "manage"}`, + admission: { environment, viewer: expect.objectContaining({ kind: "user" }) }, + }); + } + }); + + it("checks service ceilings before lookup and attributes no actorless permissions", async () => { + const ctx = context(); + ctx.principal = { kind: "service", service: "github-bot", actor: null }; + await expect(evaluateOwnedResourceAdmission(environmentRequirement, {}, ctx)).resolves.toEqual({ + kind: "error", + status: 400, + response: { error: "Invalid environment route" }, + }); + await expect( + evaluateOwnedResourceAdmission(environmentRequirement, { id: "resource" }, ctx) + ).resolves.toEqual({ + kind: "denied", + status: 403, + response: { error: "Forbidden", code: "service_capability_required" }, + reasonCode: "service_capability_required", + reason: "Forbidden", + }); + expect(EnvironmentStore.prototype.getById).not.toHaveBeenCalled(); + ctx.principal = { kind: "service", service: "slack-bot", actor: null }; + ctx.authorization = undefined; + vi.mocked(EnvironmentStore.prototype.getById).mockResolvedValue({ + ...environment, + owner_team_id: null, + }); + await expect( + evaluateOwnedResourceAdmission( + { ...environmentRequirement, need: "use" }, + { id: "resource" }, + ctx + ) + ).resolves.toMatchObject({ kind: "allowed", effectivePermission: null }); + }); + + it("hides team environments from actorless bots like missing ones", async () => { + const ctx = context(); + ctx.principal = { kind: "service", service: "github-bot", actor: null }; + ctx.authorization = undefined; + for (const need of ["read", "use"] as const) { + await expect( + evaluateOwnedResourceAdmission({ ...environmentRequirement, need }, { id: "resource" }, ctx) + ).resolves.toMatchObject({ kind: "denied", status: 404 }); + } + }); + + it("refuses to hand a handler an environment that route admission did not load", () => { + expect(() => admittedEnvironment(context())).toThrow("Route did not admit an environment"); + }); + + it("applies canonical admission to session targets before owner mismatch", async () => { + const ctx = context(); + ctx.authorization!.permissions = ["environments.use"]; + ctx.authorization!.suspendedAt = 1; + const response = await authorizeEnvironmentTarget(ctx, { + environmentId: "environment", + ownerTeamId: null, + }); + // Same concealment as route admission, rather than the 409 ownership mismatch. + expect(response?.status).toBe(404); + await expect(response?.json()).resolves.toEqual({ error: "Environment not found" }); + }); + + it("allows sandbox clone inheritance with dangling environment provenance", async () => { + vi.mocked(EnvironmentStore.prototype.getById).mockResolvedValue(null); + const ctx = context(); + ctx.principal = { kind: "sandbox", sessionId: "parent" }; + delete ctx.authorization; + await expect( + authorizeEnvironmentTarget(ctx, { environmentId: "env_deleted", ownerTeamId: "team" }) + ).resolves.toBeNull(); + expect(EnvironmentStore.prototype.getById).toHaveBeenCalledOnce(); + }); + + it("tolerates a deleted inherited environment for a user, but admits existing ones", async () => { + vi.mocked(EnvironmentStore.prototype.getById).mockResolvedValue(null); + const ctx = context(); + ctx.authorization!.permissions = ["environments.use"]; + await expect( + authorizeEnvironmentTarget(ctx, { + environmentId: "env_deleted", + ownerTeamId: "team", + inherited: true, + }) + ).resolves.toBeNull(); + // A newly chosen target still gets the canonical 404. + expect( + (await authorizeEnvironmentTarget(ctx, { environmentId: "env_deleted", ownerTeamId: "team" })) + ?.status + ).toBe(404); + + vi.mocked(EnvironmentStore.prototype.getById).mockResolvedValue(environment); + const mismatch = await authorizeEnvironmentTarget(ctx, { + environmentId: "environment", + ownerTeamId: null, + inherited: true, + }); + expect(mismatch?.status).toBe(409); + ctx.sessionMemberships = new Map(); + const hidden = await authorizeEnvironmentTarget(ctx, { + environmentId: "environment", + ownerTeamId: "team", + inherited: true, + }); + expect(hidden?.status).toBe(404); + }); + + it("binds a sandbox's inherited team environment to the destination team", async () => { + const ctx = context(); + ctx.principal = { kind: "sandbox", sessionId: "parent" }; + delete ctx.authorization; + const response = await authorizeEnvironmentTarget(ctx, { + environmentId: "environment", + ownerTeamId: null, + }); + expect(response?.status).toBe(409); + }); + + it("leaves environment admission out of the permission/grant preflight", async () => { + const ctx = context(); + ctx.authorization!.permissions = ["environments.use"]; + await expect( + authorizeSessionTarget(ctx, { teamId: null, environmentId: "environment" }) + ).resolves.toBeNull(); + expect(EnvironmentStore.prototype.getById).not.toHaveBeenCalled(); + }); +}); + +describe("automation admission outcomes", () => { + // Full row validation is covered by D1 tests; admission consumes only ownership fields. + const automation = { + id: "automation", + owner_team_id: "team", + user_id: null, + created_by: "legacy-owner", + } as AutomationRow; + const canonicalAutomation = { ...automation, user_id: "user" }; + const requirement = { kind: "automation", operation: "manage", automationIdParam: "id" } as const; + + function automationContext(permissions: PermissionId[], role: TeamRole | null = "member") { + const ctx = context(); + ctx.authorization!.permissions = permissions; + ctx.sessionMemberships = new Map(role ? [["team", role]] : []); + return ctx; + } + + beforeEach(() => { + vi.spyOn(AutomationStore.prototype, "getById").mockResolvedValue(automation); + vi.spyOn(AutomationStore.prototype, "resolveCanonicalOwner").mockResolvedValue( + canonicalAutomation + ); + }); + afterEach(() => vi.restoreAllMocks()); + + it("hides invisible automations like missing ones, retaining denial audit context", async () => { + const ctx = automationContext(["automations.manage.own"], null); + const hidden = await evaluateOwnedResourceAdmission(requirement, { id: "automation" }, ctx); + expect(ctx.automationAdmission?.automation).toBe(automation); + expect(AutomationStore.prototype.resolveCanonicalOwner).not.toHaveBeenCalled(); + vi.mocked(AutomationStore.prototype.getById).mockResolvedValue(null); + const missingCtx = automationContext(["automations.manage.own"]); + expect(await evaluateOwnedResourceAdmission(requirement, { id: "id" }, missingCtx)).toEqual( + hidden + ); + expect(hidden).toEqual({ + kind: "denied", + status: 404, + response: { error: "Automation not found" }, + reasonCode: "automation_not_visible", + reason: "Automation not found", + }); + expect(missingCtx.automationAdmission).toBeUndefined(); + }); + + it.each([ + { operation: "manage", permission: "automations.manage.own" }, + { operation: "manage", permission: "automations.manage.any" }, + { operation: "trigger", permission: "automations.trigger.own" }, + ] as const)( + "allows $permission without read permission against the canonical owner", + async ({ operation, permission }) => { + const ctx = automationContext([permission]); + await expect( + evaluateOwnedResourceAdmission({ ...requirement, operation }, { id: "automation" }, ctx) + ).resolves.toEqual({ kind: "allowed", effectivePermission: permission }); + expect(ctx.automationAdmission?.automation).toBe(canonicalAutomation); + } + ); + + it("maps visible read and action denials to 403", async () => { + vi.mocked(AutomationStore.prototype.resolveCanonicalOwner).mockResolvedValue({ + ...automation, + user_id: "other-user", + }); + for (const read of [true, false]) { + const ctx = automationContext(["automations.manage.own"]); + const reason = read ? "missing_permission" : "not_owner_or_lead"; + expect( + await evaluateOwnedResourceAdmission( + read ? { ...requirement, operation: "read" } : requirement, + { id: "automation" }, + ctx + ) + ).toEqual({ + kind: "denied", + status: 403, + reasonCode: reason, + reason: "Forbidden", + response: { error: "Forbidden", code: "automation_action_denied", reason_code: reason }, + }); + } + }); + + it("checks service ceilings before lookup and attributes no actorless permissions", async () => { + const ctx = context(); + ctx.principal = { kind: "service", service: "github-bot", actor: null }; + await expect( + evaluateOwnedResourceAdmission(requirement, { id: "automation" }, ctx) + ).resolves.toMatchObject({ kind: "denied", reasonCode: "service_capability_required" }); + expect(AutomationStore.prototype.getById).not.toHaveBeenCalled(); + ctx.principal = { kind: "service", service: "slack-bot", actor: null }; + ctx.authorization = undefined; + await expect( + evaluateOwnedResourceAdmission( + { ...requirement, operation: "read" }, + { id: "automation" }, + ctx + ) + ).resolves.toEqual({ kind: "allowed", effectivePermission: null }); + }); +}); diff --git a/packages/control-plane/src/authorization/owned-resource-admission.ts b/packages/control-plane/src/authorization/owned-resource-admission.ts new file mode 100644 index 0000000000..21018d316e --- /dev/null +++ b/packages/control-plane/src/authorization/owned-resource-admission.ts @@ -0,0 +1,233 @@ +import { + checkAutomationAccess, + checkEnvironmentAccess, + type SessionViewer, +} from "@open-inspect/shared"; +import { + SCOPED_PERMISSION_PAIRS, + resolveScopedPermission, + type PermissionId, +} from "@open-inspect/shared/rbac"; +import { AutomationStore } from "../db/automation-store"; +import { EnvironmentStore, type EnvironmentRow } from "../db/environments"; +import { json } from "../http/responses"; +import type { RequestContext } from "../http/request-context"; +import type { RouteAuthorizationRequirement, RouteParams } from "../routes/shared"; +import { resourceViewer } from "./resource-viewer"; +import { serviceAllowsPermission } from "./service-permissions"; + +/** The environment an admission loaded, with the viewer it was decided for. */ +export interface EnvironmentAdmission { + environment: EnvironmentRow; + viewer: SessionViewer; +} + +/** + * Resource decisions without accumulated route evidence or HTTP response construction. Denials + * carry the loaded environment, when there is one, so audits can attribute its owner team. + */ +export type OwnedResourceAdmissionOutcome = + | { kind: "allowed"; effectivePermission: PermissionId | null; admission: EnvironmentAdmission } + | { + kind: "denied"; + admission?: EnvironmentAdmission; + response: { error: string; code?: string; reason_code?: string }; + status: 403 | 404; + reasonCode: string; + reason: string; + failedPermission?: PermissionId; + } + | { kind: "error"; response: { error: string }; status: 400 }; + +/** Automation decisions; an admitted automation is recorded on `ctx.automationAdmission`. */ +export type AutomationAdmissionOutcome = + | { kind: "allowed"; effectivePermission: PermissionId | null } + | Omit, "admission"> + | Extract; + +type EnvironmentNeed = Extract["need"]; + +/** + * Route-parameter admission for owned automations and environments. Environments delegate to + * {@link evaluateEnvironmentAdmission}; infrastructure failures propagate to the router. + */ +export async function evaluateOwnedResourceAdmission( + requirement: Extract, + params: RouteParams, + ctx: RequestContext +): Promise; +export async function evaluateOwnedResourceAdmission( + requirement: Extract, + params: RouteParams, + ctx: RequestContext +): Promise; +export async function evaluateOwnedResourceAdmission( + requirement: Extract, + params: RouteParams, + ctx: RequestContext +): Promise; +export async function evaluateOwnedResourceAdmission( + requirement: Extract, + params: RouteParams, + ctx: RequestContext +): Promise { + if (requirement.kind === "automation") { + return evaluateAutomationAdmission(requirement, params, ctx); + } + const id = params[requirement.idParam]; + if (!id) return { kind: "error", response: { error: "Invalid environment route" }, status: 400 }; + return evaluateEnvironmentAdmission(ctx, id, requirement.need); +} + +async function evaluateAutomationAdmission( + requirement: Extract, + params: RouteParams, + ctx: RequestContext +): Promise { + if ( + ctx.principal?.kind === "service" && + !serviceAllowsPermission(ctx.principal.service, "automations.read") + ) { + return { + kind: "denied", + response: { error: "Forbidden", code: "service_capability_required" }, + status: 403, + reasonCode: "service_capability_required", + reason: "Forbidden", + }; + } + const automationId = params[requirement.automationIdParam]; + if (!automationId) { + return { kind: "error", response: { error: "Invalid automation route" }, status: 400 }; + } + + const store = new AutomationStore(ctx.db); + const storedAutomation = await store.getById(automationId); + const viewer = await resourceViewer(ctx); + const row = storedAutomation && { + ownerTeamId: storedAutomation.owner_team_id, + executorUserId: storedAutomation.user_id, + }; + if (storedAutomation) ctx.automationAdmission = { automation: storedAutomation, viewer }; + const read = row && checkAutomationAccess(viewer, row, "read"); + // Missing read permission does not block independently granted management or triggering. + if (!storedAutomation || (read && !read.allowed && read.reason !== "missing_permission")) { + return { + kind: "denied", + response: { error: "Automation not found" }, + status: 404, + reasonCode: "automation_not_visible", + reason: "Automation not found", + }; + } + const automation = await store.resolveCanonicalOwner(storedAutomation); + const decision = checkAutomationAccess( + viewer, + { ownerTeamId: automation.owner_team_id, executorUserId: automation.user_id }, + requirement.operation + ); + if (!decision.allowed) { + return { + kind: "denied", + response: automationActionDeniedBody(decision.reason), + status: 403, + reasonCode: decision.reason, + reason: "Forbidden", + }; + } + let effectivePermission: PermissionId | null = null; + if (viewer.kind === "user") { + if (requirement.operation === "read") effectivePermission = "automations.read"; + else { + const stem = `automations.${requirement.operation}` as const; + const scope = resolveScopedPermission(stem, viewer.permissions); + if (scope) effectivePermission = SCOPED_PERMISSION_PAIRS[stem][scope]; + } + } + ctx.automationAdmission = { automation, viewer }; + return { kind: "allowed", effectivePermission }; +} + +/** + * Canonical environment admission for an ID from a path, query, or body: hidden environments + * are indistinguishable from missing ones, and visible denials carry their reason. Infrastructure + * failures propagate to the caller (the router's authorization-unavailable boundary). + */ +export async function evaluateEnvironmentAdmission( + ctx: RequestContext, + id: string, + need: EnvironmentNeed +): Promise { + const permission = `environments.${need}` as const; + if ( + ctx.principal?.kind === "service" && + !serviceAllowsPermission(ctx.principal.service, permission) + ) { + return { + kind: "denied", + response: { error: "Forbidden", code: "service_capability_required" }, + status: 403, + reasonCode: "service_capability_required", + reason: "Forbidden", + }; + } + const environment = await new EnvironmentStore(ctx.db).getById(id); + const viewer = await resourceViewer(ctx); + const admission = environment ? { environment, viewer } : undefined; + const read = + environment && + checkEnvironmentAccess(viewer, { ownerTeamId: environment.owner_team_id }, "read"); + if (!environment || (read && !read.allowed && read.reason !== "missing_permission")) { + return { + kind: "denied", + admission, + response: { error: "Environment not found" }, + status: 404, + reasonCode: "environment_not_visible", + reason: "Environment not found", + }; + } + const decision = checkEnvironmentAccess(viewer, { ownerTeamId: environment.owner_team_id }, need); + if (!decision.allowed) { + return { + kind: "denied", + admission, + response: environmentActionDeniedBody(decision.reason), + status: 403, + reasonCode: decision.reason, + reason: "Forbidden", + failedPermission: permission, + }; + } + return { + kind: "allowed", + effectivePermission: viewer.kind === "user" ? permission : null, + admission: { environment, viewer }, + }; +} + +/** Body for a visible automation the viewer may not act on. */ +export function automationActionDeniedBody(reason: string, error = "Forbidden") { + return { error, code: "automation_action_denied", reason_code: reason }; +} + +/** Body for a visible environment the viewer may not act on. */ +export function environmentActionDeniedBody(reason: string) { + return { error: "Forbidden", code: "environment_action_denied", reason_code: reason }; +} + +/** + * The environment route admission loaded for this request. Only handlers behind an + * `environment` route requirement may call this; anything else is a routing bug. + */ +export function admittedEnvironment(ctx: RequestContext): EnvironmentAdmission { + if (!ctx.environmentAdmission) throw new Error("Route did not admit an environment"); + return ctx.environmentAdmission; +} + +/** HTTP response for an outcome that did not admit the resource. */ +export function ownedResourceAdmissionResponse( + outcome: Exclude +): Response { + return json(outcome.response, outcome.status); +} diff --git a/packages/control-plane/src/authorization/request-audit.ts b/packages/control-plane/src/authorization/request-audit.ts index edc4485356..5317db3c89 100644 --- a/packages/control-plane/src/authorization/request-audit.ts +++ b/packages/control-plane/src/authorization/request-audit.ts @@ -93,7 +93,10 @@ export async function auditRouteAuthorizationDecision(input: { ? input.teamId : input.ctx.childSessionAdmission ? input.ctx.childSessionAdmission.row.ownerTeamId - : (input.ctx.sessionAdmission?.row.ownerTeamId ?? null); + : (input.ctx.sessionAdmission?.row.ownerTeamId ?? + input.ctx.automationAdmission?.automation.owner_team_id ?? + input.ctx.environmentAdmission?.environment.owner_team_id ?? + null); const metadata = { schema: AUTHORIZATION_DECISION_METADATA_SCHEMA, httpMethod: input.method, diff --git a/packages/control-plane/src/authorization/resource-viewer.ts b/packages/control-plane/src/authorization/resource-viewer.ts new file mode 100644 index 0000000000..e72ee6a967 --- /dev/null +++ b/packages/control-plane/src/authorization/resource-viewer.ts @@ -0,0 +1,14 @@ +import type { SessionViewer } from "@open-inspect/shared"; +import { TeamMembershipStore } from "../db/team-memberships"; +import type { RequestContext } from "../http/request-context"; +import { viewerFromContext } from "./session-admission"; + +/** Reuse one membership snapshot for owned-resource decisions in a request. */ +export async function resourceViewer(ctx: RequestContext): Promise { + const memberships = ctx.authorization + ? (ctx.sessionMemberships ??= await new TeamMembershipStore(ctx.db).listForUser( + ctx.authorization.userId + )) + : new Map(); + return viewerFromContext(ctx, memberships); +} diff --git a/packages/control-plane/src/autofix/handler.test.ts b/packages/control-plane/src/autofix/handler.test.ts new file mode 100644 index 0000000000..625341dfe9 --- /dev/null +++ b/packages/control-plane/src/autofix/handler.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { GitHubAutofixEnvelope } from "@open-inspect/shared"; +import { SessionIndexStore, type SessionEntry } from "../db/session-index"; +import { SessionRepositoryStore } from "../db/session-repositories"; +import type { SqlDatabase } from "../db/sql-database"; +import type { JobDeps } from "../jobs"; +import { readCachedInstallationRepositories } from "../repos/cache"; +import { SourceControlProviderError } from "../source-control"; +import type { Env } from "../types"; +import { handleAutofixJob } from "./handler"; +import { AutofixService } from "./service"; + +vi.mock("../repos/cache", () => ({ readCachedInstallationRepositories: vi.fn() })); +vi.mock("./service", () => ({ + AutofixService: vi.fn(function () { + return { + process: async () => ({ kind: "completed", decision: "skipped", reason: "disabled" }), + }; + }), +})); + +const ENVELOPE: GitHubAutofixEnvelope = { + version: 1, + eventType: "issue_comment", + action: "created", + deliveryId: "delivery-1", + providerObject: { kind: "pr_comment", id: "1234" }, + repository: { id: "99", owner: "acme", name: "widgets" }, + pullRequestNumber: 42, + receivedAt: "2026-07-30T05:00:00.000Z", +}; + +const WORKSPACE_SESSION = { id: "owning-public-session", ownerTeamId: null } as SessionEntry; + +describe("autofix credential scope composition", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(SessionRepositoryStore.prototype, "listRepositoryIds").mockResolvedValue([ + { repoOwner: "acme", repoName: "widgets", repoId: 99 }, + { repoOwner: "acme", repoName: "web", repoId: 123 }, + ]); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + async function createHarness() { + const db = {} as SqlDatabase; + const env = { GITHUB_BOT_USERNAME: "open-inspect[bot]", TEAMS_ENFORCEMENT: "off" } as Env; + await handleAutofixJob(ENVELOPE, { attempts: 1, maxAttempts: 5 }, { + db, + env, + correlation: { trace_id: "trace-1", request_id: "request-1" }, + } as JobDeps); + return { env, resolveCredentialScope: vi.mocked(AutofixService).mock.calls[0][7] }; + } + + it("resolves the owner session's persisted repositories from D1", async () => { + vi.spyOn(SessionIndexStore.prototype, "get").mockResolvedValue(WORKSPACE_SESSION); + const h = await createHarness(); + + expect(await h.resolveCredentialScope("owning-public-session")).toEqual({ + kind: "repositories", + repositoryIds: [99, 123], + }); + expect(SessionRepositoryStore.prototype.listRepositoryIds).toHaveBeenCalledWith( + "owning-public-session" + ); + }); + + it("loads the cached catalog from env and propagates its failure rather than broadening", async () => { + vi.spyOn(SessionIndexStore.prototype, "get").mockResolvedValue(WORKSPACE_SESSION); + vi.mocked(SessionRepositoryStore.prototype.listRepositoryIds).mockResolvedValue([ + { repoOwner: "acme", repoName: "widgets", repoId: null }, + ]); + const error = new SourceControlProviderError("Cached catalog unavailable", "permanent"); + vi.mocked(readCachedInstallationRepositories).mockRejectedValue(error); + const h = await createHarness(); + + await expect(h.resolveCredentialScope("owning-public-session")).rejects.toBe(error); + expect(readCachedInstallationRepositories).toHaveBeenCalledExactlyOnceWith(h.env); + }); + + it("throws a permanent credential error rather than defaulting a missing D1 session to all", async () => { + vi.spyOn(SessionIndexStore.prototype, "get").mockResolvedValue(null); + const h = await createHarness(); + + await expect(h.resolveCredentialScope("owning-public-session")).rejects.toMatchObject({ + errorType: "permanent", + message: "Cannot resolve credential scope: session not found", + }); + }); +}); diff --git a/packages/control-plane/src/autofix/handler.ts b/packages/control-plane/src/autofix/handler.ts index 455ac7599d..65c902f355 100644 --- a/packages/control-plane/src/autofix/handler.ts +++ b/packages/control-plane/src/autofix/handler.ts @@ -9,8 +9,10 @@ import { IntegrationSettingsStore } from "../db/integration-settings"; import { PrAutofixFeedbackStore } from "../db/pr-autofix-feedback-store"; import { SessionPullRequestStore } from "../db/session-pull-request-store"; import type { JobDelivery, JobDeps, JobOutcome } from "../jobs"; +import { readCachedInstallationRepositories } from "../repos/cache"; import { createSessionRuntimeClient } from "../session/runtime-client"; import { GitHubSourceControlProvider } from "../source-control/providers/github-provider"; +import { resolveSessionCredentialScope } from "../source-control/session-scope"; import { AutofixJobHandler } from "./job-handler"; import { AutofixService } from "./service"; @@ -48,6 +50,8 @@ export async function handleAutofixJob( userAgent: resolveAppName(env), }); const sessions = createSessionRuntimeClient(env, correlation); + const resolveCredentialScope = (sessionId: string) => + resolveSessionCredentialScope(db, sessionId, () => readCachedInstallationRepositories(env)); const service = new AutofixService( feedbackStore, new SessionPullRequestStore(db), @@ -63,7 +67,8 @@ export async function handleAutofixJob( github, sessions, env.GITHUB_BOT_USERNAME, - () => Date.now() + () => Date.now(), + resolveCredentialScope ); return new AutofixJobHandler(service, feedbackStore, () => Date.now()).handle(envelope, delivery); } diff --git a/packages/control-plane/src/autofix/service.test.ts b/packages/control-plane/src/autofix/service.test.ts index 6b519d4073..257b1f3615 100644 --- a/packages/control-plane/src/autofix/service.test.ts +++ b/packages/control-plane/src/autofix/service.test.ts @@ -7,9 +7,21 @@ import { import { AutofixService } from "./service"; import type { GitHubPullRequestFeedback } from "../source-control/providers/github-provider"; import { SourceControlProviderError } from "../source-control/errors"; +import type { CredentialScope } from "../source-control"; type ReviewFeedback = Extract; +const PR_COMMENT_ENVELOPE: GitHubAutofixEnvelope = { + version: 1, + eventType: "issue_comment", + action: "created", + deliveryId: "delivery-1", + providerObject: { kind: "pr_comment", id: "1234" }, + repository: { id: "99", owner: "acme", name: "widgets" }, + pullRequestNumber: 42, + receivedAt: "2026-07-30T05:00:00.000Z", +}; + const OPEN_INSPECT_REVIEW_ENVELOPE: GitHubAutofixEnvelope = { version: 1, eventType: "pull_request_review", @@ -101,6 +113,8 @@ function buildService() { const sessions = { fetch: vi.fn(async () => Response.json({ kind: "enqueued", messageId: "message-1" })), }; + const credentialScope: CredentialScope = { kind: "repositories", repositoryIds: [99, 123] }; + const resolveCredentialScope = vi.fn(async () => credentialScope); const service = new AutofixService( feedbackStore, pullRequests, @@ -108,26 +122,27 @@ function buildService() { github, sessions, "open-inspect[bot]", - () => 2_000 + () => 2_000, + resolveCredentialScope ); - return { service, feedbackStore, pullRequests, settings, github, sessions }; + return { + service, + feedbackStore, + pullRequests, + settings, + github, + sessions, + credentialScope, + resolveCredentialScope, + }; } describe("AutofixService", () => { it("dispatches eligible human PR feedback into the owning session", async () => { const h = buildService(); - const result = await h.service.process({ - version: 1, - eventType: "issue_comment", - action: "created", - deliveryId: "delivery-1", - providerObject: { kind: "pr_comment", id: "1234" }, - repository: { id: "99", owner: "acme", name: "widgets" }, - pullRequestNumber: 42, - receivedAt: "2026-07-30T05:00:00.000Z", - }); + const result = await h.service.process(PR_COMMENT_ENVELOPE); expect(result).toEqual({ kind: "completed", @@ -135,11 +150,24 @@ describe("AutofixService", () => { reason: "enqueued", messageId: "message-1", }); - expect(h.github.hasPullRequestWritePermission).toHaveBeenCalledWith({ - owner: "acme", - name: "widgets", - authorLogin: "alice", - }); + expect(h.resolveCredentialScope).toHaveBeenCalledWith("session-1"); + expect(h.github.getPullRequest).toHaveBeenCalledWith( + { owner: "acme", name: "widgets", number: 42, repositoryExternalId: "99" }, + h.credentialScope + ); + expect(h.github.getPullRequestFeedback).toHaveBeenCalledWith( + { + owner: "acme", + name: "widgets", + pullRequestNumber: 42, + providerObject: { kind: "pr_comment", id: "1234" }, + }, + h.credentialScope + ); + expect(h.github.hasPullRequestWritePermission).toHaveBeenCalledWith( + { owner: "acme", name: "widgets", authorLogin: "alice" }, + h.credentialScope + ); expect(h.feedbackStore.markDispatchAttempted).toHaveBeenCalledBefore(h.sessions.fetch); expect(h.sessions.fetch).toHaveBeenCalledWith( "session-1", @@ -193,6 +221,20 @@ describe("AutofixService", () => { ); }); + it("fails closed before all provider content reads when owner scope cannot be resolved", async () => { + const h = buildService(); + const error = new SourceControlProviderError( + "Cannot resolve credential scope: session not found", + "permanent" + ); + h.resolveCredentialScope.mockRejectedValue(error); + + await expect(h.service.process(PR_COMMENT_ENVELOPE)).rejects.toBe(error); + + expect(h.github.getPullRequest).not.toHaveBeenCalled(); + expect(h.sessions.fetch).not.toHaveBeenCalled(); + }); + it("returns the winning queued decision when a concurrent skip loses its transition", async () => { const h = buildService(); h.settings.resolve.mockResolvedValue({ @@ -307,6 +349,15 @@ describe("AutofixService", () => { }); expect(result).toMatchObject({ decision: "queued", messageId: "message-1" }); + expect(h.github.getPullRequestFeedback).toHaveBeenCalledWith( + { + owner: "acme", + name: "widgets", + pullRequestNumber: 42, + providerObject: { kind: "review", id: "5678" }, + }, + h.credentialScope + ); expect(h.github.hasPullRequestWritePermission).not.toHaveBeenCalled(); expect(h.sessions.fetch).toHaveBeenCalledWith( "session-1", @@ -542,11 +593,10 @@ describe("AutofixService", () => { decision: "skipped", reason: "author_lacks_write_permission", }); - expect(h.github.hasPullRequestWritePermission).toHaveBeenCalledWith({ - owner: "acme", - name: "widgets", - authorLogin: "Open-Inspect[bot]", - }); + expect(h.github.hasPullRequestWritePermission).toHaveBeenCalledWith( + { owner: "acme", name: "widgets", authorLogin: "Open-Inspect[bot]" }, + h.credentialScope + ); expect(h.sessions.fetch).not.toHaveBeenCalled(); }); diff --git a/packages/control-plane/src/autofix/service.ts b/packages/control-plane/src/autofix/service.ts index 9331840150..ef97ef3165 100644 --- a/packages/control-plane/src/autofix/service.ts +++ b/packages/control-plane/src/autofix/service.ts @@ -13,6 +13,7 @@ import type { GetGitHubPullRequestFeedbackConfig, } from "../source-control/providers/github-provider"; import { SourceControlProviderError } from "../source-control/errors"; +import type { CredentialScope } from "../source-control"; import { SessionInternalPaths, type SessionInternalPath } from "../session/contracts"; interface FeedbackReceipt { @@ -72,24 +73,31 @@ interface AutofixSettingsResolver { } interface GitHubAutofixProvider { - getPullRequest(config: { - owner: string; - name: string; - number: number; - repositoryExternalId: string; - }): Promise<{ + getPullRequest( + config: { + owner: string; + name: string; + number: number; + repositoryExternalId: string; + }, + scope: CredentialScope + ): Promise<{ lifecycleState: "open" | "closed" | "merged"; repoOwner: string; repoName: string; }>; getPullRequestFeedback( - config: GetGitHubPullRequestFeedbackConfig + config: GetGitHubPullRequestFeedbackConfig, + scope: CredentialScope ): Promise; - hasPullRequestWritePermission(config: { - owner: string; - name: string; - authorLogin: string; - }): Promise; + hasPullRequestWritePermission( + config: { + owner: string; + name: string; + authorLogin: string; + }, + scope: CredentialScope + ): Promise; } interface SessionClient { @@ -194,7 +202,8 @@ export class AutofixService { private readonly github: GitHubAutofixProvider, private readonly sessions: SessionClient, private readonly botUsername: string, - private readonly now: () => number + private readonly now: () => number, + private readonly resolveCredentialScope: (sessionId: string) => Promise ) {} async process(envelope: GitHubAutofixEnvelope): Promise { @@ -289,12 +298,16 @@ export class AutofixService { return this.skip(receipt.feedbackKey, "reviews_disabled", decidedAt); } - const pullRequest = await this.github.getPullRequest({ - owner: owner.repoOwner, - name: owner.repoName, - number: owner.prNumber, - repositoryExternalId: envelope.repository.id, - }); + const credentialScope = await this.resolveCredentialScope(owner.sessionId); + const pullRequest = await this.github.getPullRequest( + { + owner: owner.repoOwner, + name: owner.repoName, + number: owner.prNumber, + repositoryExternalId: envelope.repository.id, + }, + credentialScope + ); if (pullRequest.lifecycleState !== "open") { return this.skip(receipt.feedbackKey, "pull_request_not_open", decidedAt); } @@ -304,22 +317,17 @@ export class AutofixService { name: pullRequest.repoName, pullRequestNumber: owner.prNumber, }; - const feedback = + const feedbackConfig: GetGitHubPullRequestFeedbackConfig = envelope.providerObject.kind === "pr_comment" - ? await this.github.getPullRequestFeedback({ + ? { ...feedbackLocation, - providerObject: { - kind: "pr_comment", - id: envelope.providerObject.id, - }, - }) - : await this.github.getPullRequestFeedback({ + providerObject: { kind: "pr_comment", id: envelope.providerObject.id }, + } + : { ...feedbackLocation, - providerObject: { - kind: "review", - id: envelope.providerObject.id, - }, - }); + providerObject: { kind: "review", id: envelope.providerObject.id }, + }; + const feedback = await this.github.getPullRequestFeedback(feedbackConfig, credentialScope); await this.feedbackStore.attachContext(receipt.feedbackKey, { artifactId: owner.artifactId, sessionId: owner.sessionId, @@ -333,7 +341,8 @@ export class AutofixService { feedback, resolved.autofix, pullRequest.repoOwner, - pullRequest.repoName + pullRequest.repoName, + credentialScope ); if (eligibilityReason) { return this.skip(receipt.feedbackKey, eligibilityReason, decidedAt); @@ -431,7 +440,8 @@ export class AutofixService { feedback: GitHubPullRequestFeedback, settings: ResolvedGitHubAutofixSettings, owner: string, - name: string + name: string, + credentialScope: CredentialScope ): Promise { const authorType = feedback.author.type.toLowerCase(); const authorLogin = feedback.author.login.toLowerCase(); @@ -452,11 +462,14 @@ export class AutofixService { ) { return "explicit_mention"; } - const canWrite = await this.github.hasPullRequestWritePermission({ - owner, - name, - authorLogin: feedback.author.login, - }); + const canWrite = await this.github.hasPullRequestWritePermission( + { + owner, + name, + authorLogin: feedback.author.login, + }, + credentialScope + ); if (!canWrite) return "author_lacks_write_permission"; } else if (authorType === "bot") { if (feedback.kind !== "review") return "bot_pr_comment"; diff --git a/packages/control-plane/src/automation/authorization-guard.test.ts b/packages/control-plane/src/automation/authorization-guard.test.ts index 4844f43df6..965f090773 100644 --- a/packages/control-plane/src/automation/authorization-guard.test.ts +++ b/packages/control-plane/src/automation/authorization-guard.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { SqlDatabase } from "../db/sql-database"; -import { isAutomationExecutionAuthorized, isPrincipalAuthorized } from "./authorization-guard"; +import { isAutomationExecutionAuthorized } from "./authorization-guard"; function recordingDb(result: { authorized: number } | null = { authorized: 1 }): { db: SqlDatabase; @@ -43,6 +43,9 @@ describe("automation execution authorization", () => { expect(bindings).toHaveLength(1); expect(bindings[0]?.[0]).toBe("automation-1"); expect(queries[0]).toContain("a.id = ? AND a.deleted_at IS NULL"); + expect(queries[0]).toContain("tm.team_id = a.owner_team_id AND tm.user_id = u.id"); + expect(queries[0]).toContain("t.archived_at IS NULL"); + expect(queries[0]).toContain("a.owner_team_id IS NULL OR"); expect(queries[0]).not.toContain("automation_repositories"); expect(queries[0]).not.toContain("automation_environments"); }); @@ -63,15 +66,6 @@ describe("automation execution authorization", () => { expect(queries[0]).toContain("JOIN users u ON u.id = ?"); }); - it("authorizes collaboration without requiring automation launch permissions", async () => { - const { db, bindings, queries } = recordingDb(); - - await expect(isPrincipalAuthorized(db, "actor-1", "sessions.collaborate")).resolves.toBe(true); - - expect(bindings[0]?.[0]).toBe("actor-1"); - expect(queries[0]).not.toContain("automations"); - }); - it.each([ { name: "denied", result: { authorized: 0 } }, { name: "missing row", result: null }, @@ -86,13 +80,4 @@ describe("automation execution authorization", () => { }) ).resolves.toBe(false); }); - - it.each([ - { name: "denied", result: { authorized: 0 } }, - { name: "missing row", result: null }, - ])("fails closed when principal authorization is $name", async ({ result }) => { - const { db } = recordingDb(result); - - await expect(isPrincipalAuthorized(db, "actor-1", "sessions.collaborate")).resolves.toBe(false); - }); }); diff --git a/packages/control-plane/src/automation/authorization-guard.ts b/packages/control-plane/src/automation/authorization-guard.ts index e99aa62759..e11ed17d95 100644 --- a/packages/control-plane/src/automation/authorization-guard.ts +++ b/packages/control-plane/src/automation/authorization-guard.ts @@ -1,58 +1,77 @@ -import { type PermissionId } from "@open-inspect/shared/rbac"; import { rolePermissionPredicate } from "../authorization/permission-sql"; import type { SqlDatabase } from "../db/sql-database"; -interface SqlPredicate { +export interface SqlPredicate { sql: string; values: readonly unknown[]; } -/** Immutable execution requirements derived from the targets selected for one firing. */ +/** `stored` derives the requirement from the automation's target rows when evaluated. */ +type TargetUseRequirement = boolean | "stored"; + +/** Execution requirements, normally derived from the targets selected for one firing. */ export interface AutomationExecutionAuthorizationRequest { automationId: string; executionUserId?: string; - requiresRepositoryUse: boolean; - requiresEnvironmentUse: boolean; + requiresRepositoryUse: TargetUseRequirement; + requiresEnvironmentUse: TargetUseRequirement; +} + +function targetUseGuard( + requirement: TargetUseRequirement, + table: "automation_repositories" | "automation_environments", + permission: "repositories.use" | "environments.use" +): SqlPredicate { + if (requirement === false) return { sql: "", values: [] }; + const guard = rolePermissionPredicate(permission); + return { + sql: + requirement === "stored" + ? `AND (NOT EXISTS (SELECT 1 FROM ${table} stored_target WHERE stored_target.automation_id = a.id) + OR ${guard.sql})` + : `AND ${guard.sql}`, + values: guard.values, + }; } -function executionPredicate(request: AutomationExecutionAuthorizationRequest): SqlPredicate { +export function automationExecutionPredicate( + request: AutomationExecutionAuthorizationRequest +): SqlPredicate { const createGuard = rolePermissionPredicate("sessions.create"); - const repositoryGuard = rolePermissionPredicate("repositories.use"); - const environmentGuard = rolePermissionPredicate("environments.use"); + const repositoryGuard = targetUseGuard( + request.requiresRepositoryUse, + "automation_repositories", + "repositories.use" + ); + const environmentGuard = targetUseGuard( + request.requiresEnvironmentUse, + "automation_environments", + "environments.use" + ); return { sql: `EXISTS ( SELECT 1 FROM automations a JOIN users u ON u.id = ${request.executionUserId ? "?" : "a.user_id"} JOIN user_role_assignments ura ON ura.user_id = u.id JOIN roles r ON r.id = ura.role_id + LEFT JOIN teams t ON t.id = a.owner_team_id + LEFT JOIN team_memberships tm ON tm.team_id = a.owner_team_id AND tm.user_id = u.id WHERE a.id = ? AND a.deleted_at IS NULL AND u.suspended_at IS NULL + AND (a.owner_team_id IS NULL OR (tm.user_id IS NOT NULL AND t.id IS NOT NULL AND t.archived_at IS NULL)) AND ${createGuard.sql} - ${request.requiresRepositoryUse ? `AND ${repositoryGuard.sql}` : ""} - ${request.requiresEnvironmentUse ? `AND ${environmentGuard.sql}` : ""} + ${repositoryGuard.sql} + ${environmentGuard.sql} )`, values: [ ...(request.executionUserId ? [request.executionUserId] : []), request.automationId, ...createGuard.values, - ...(request.requiresRepositoryUse ? repositoryGuard.values : []), - ...(request.requiresEnvironmentUse ? environmentGuard.values : []), + ...repositoryGuard.values, + ...environmentGuard.values, ], }; } -function principalPredicate(userId: string, permission: PermissionId): SqlPredicate { - const permissionGuard = rolePermissionPredicate(permission); - return { - sql: `EXISTS ( - SELECT 1 FROM users u - JOIN user_role_assignments ura ON ura.user_id = u.id - JOIN roles r ON r.id = ura.role_id - WHERE u.id = ? AND u.suspended_at IS NULL AND ${permissionGuard.sql} - )`, - values: [userId, ...permissionGuard.values], - }; -} - /** * Revalidates that an automation's execution principal may create its session and use its targets. * @@ -67,21 +86,7 @@ export async function isAutomationExecutionAuthorized( db: SqlDatabase, request: AutomationExecutionAuthorizationRequest ): Promise { - const predicate = executionPredicate(request); - const row = await db - .prepare(`SELECT CASE WHEN (${predicate.sql}) THEN 1 ELSE 0 END AS authorized`) - .bind(...predicate.values) - .first<{ authorized: number }>(); - return row?.authorized === 1; -} - -/** Check one canonical principal for a permission without imposing automation-launch grants. */ -export async function isPrincipalAuthorized( - db: SqlDatabase, - userId: string, - permission: PermissionId -): Promise { - const predicate = principalPredicate(userId, permission); + const predicate = automationExecutionPredicate(request); const row = await db .prepare(`SELECT CASE WHEN (${predicate.sql}) THEN 1 ELSE 0 END AS authorized`) .bind(...predicate.values) diff --git a/packages/control-plane/src/db/analytics-store.ts b/packages/control-plane/src/db/analytics-store.ts index 852f9075d5..f3b762773e 100644 --- a/packages/control-plane/src/db/analytics-store.ts +++ b/packages/control-plane/src/db/analytics-store.ts @@ -1,3 +1,4 @@ +import { isWorkspaceAdmin } from "@open-inspect/shared/rbac"; import type { AnalyticsBreakdownBy, AnalyticsBreakdownEntry, @@ -174,9 +175,7 @@ export class AnalyticsStore { prepareSummary(filters: AnalyticsFilters): SqlStatement { const { sql, binds } = scopePredicate(filters.scope, "s.spawn_source"); const visible = this.visible("s"); - const privileged = - this.readScope.kind === "user" && - (this.readScope.roleKey === "owner" || this.readScope.roleKey === "administrator"); + const privileged = this.readScope.kind === "user" && isWorkspaceAdmin(this.readScope.roleKey); const privateScope = scopePredicate(filters.scope, "private.spawn_source"); return this.db @@ -233,8 +232,7 @@ export class AnalyticsStore { activeUsers: row?.active_users ?? 0, totalCost, privateSessionsCostUsd: - this.readScope.kind === "user" && - (this.readScope.roleKey === "owner" || this.readScope.roleKey === "administrator") + this.readScope.kind === "user" && isWorkspaceAdmin(this.readScope.roleKey) ? (row?.private_sessions_cost ?? 0) : null, avgCost: totalSessions > 0 ? totalCost / totalSessions : 0, diff --git a/packages/control-plane/src/db/automation-store.test.ts b/packages/control-plane/src/db/automation-store.test.ts index ee85c45260..af219f29fd 100644 --- a/packages/control-plane/src/db/automation-store.test.ts +++ b/packages/control-plane/src/db/automation-store.test.ts @@ -18,6 +18,7 @@ import { type EnrichedRunRow, } from "./automation-store"; import { DEFAULT_AUTOMATION_MAX_CONCURRENT_RUNS } from "@open-inspect/shared/types/automations"; +import { MAX_D1_QUERY_PARAMETERS } from "./query-limits"; // ─── Fake D1 helpers ───────────────────────────────────────────────────────── @@ -329,6 +330,53 @@ describe("AutomationStore", () => { expect(result.automations).toHaveLength(1); expect(result.hasMore).toBe(false); }); + + it("projects legacy canonical owners in one lookup without repairing rows", async () => { + const { db, statements } = createFakeD1({ + allResults: [{ provider_user_id: "4242", user_id: "user-legacy" }], + }); + const legacy = { ...sampleRow, id: "legacy", user_id: null, created_by: "4242" }; + const anonymous = { ...sampleRow, id: "anon", user_id: null, created_by: "anonymous" }; + const canonical = { ...sampleRow, id: "canonical", user_id: "user-1" }; + + const rows = await new AutomationStore(db).projectCanonicalOwners([ + legacy, + anonymous, + canonical, + ]); + + expect(rows.map((row) => row.user_id)).toEqual(["user-legacy", null, "user-1"]); + expect(statements).toHaveLength(1); + expect(statements[0].sql).toContain("FROM user_identities"); + expect(statements[0].params).toEqual(["4242"]); + }); + + it("binds team visibility once, regardless of how many teams the viewer joined", async () => { + const { db, statements } = createFakeD1(); + const memberships = new Map( + Array.from({ length: MAX_D1_QUERY_PARAMETERS }, (_, i) => [`team_${i}`, "member" as const]) + ); + await new AutomationStore(db).list({ + limit: 25, + nameSearch: "sync", + teamId: "team_0", + repoOwner: "acme", + repoName: "web", + viewer: { + kind: "user", + userId: "user-1", + roleKey: "member", + permissions: ["automations.read"], + suspended: false, + memberships, + }, + }); + const [{ sql, params }] = statements; + expect(params.length).toBeLessThanOrEqual(MAX_D1_QUERY_PARAMETERS); + expect(sql.match(/\?/g)).toHaveLength(params.length); + expect(params).toContain("user-1"); + expect(params).not.toContain("team_1"); + }); }); describe("updateRun", () => { diff --git a/packages/control-plane/src/db/automation-store.ts b/packages/control-plane/src/db/automation-store.ts index 29e25f36da..f9a23e6460 100644 --- a/packages/control-plane/src/db/automation-store.ts +++ b/packages/control-plane/src/db/automation-store.ts @@ -5,6 +5,7 @@ * snake_case rows in the database, camelCase types at the API boundary. */ +import { isWorkspaceAdmin, WORKSPACE_ADMIN_ROLE_KEYS } from "@open-inspect/shared/rbac"; import { DEFAULT_HARNESS, getValidHarnessOrDefault, @@ -35,9 +36,18 @@ import { type AutomationModelProviderAuthRow, } from "./automation-model-provider-auth"; import type { SqlDatabase, SqlStatement } from "./sql-database"; +import { MAX_D1_QUERY_PARAMETERS } from "./query-limits"; import type { CreatedAtCursor } from "../created-at-cursor"; import { z } from "zod"; import { UserStore } from "./user-store"; +import { rolePermissionPredicate } from "../authorization/permission-sql"; +import { automationExecutionPredicate } from "../automation/authorization-guard"; +import type { SessionViewer } from "@open-inspect/shared"; + +/** Legacy rows predate canonical executors; their GitHub creator may now map to a user. */ +function needsCanonicalOwner(row: AutomationRow): boolean { + return !row.user_id && !!row.created_by && row.created_by !== "anonymous"; +} function escapeLikePattern(value: string): string { return value.replace(/[\\%_]/g, "\\$&"); @@ -285,6 +295,7 @@ export function toAutomation( consecutiveFailures: row.consecutive_failures, createdBy: row.created_by, userId: row.user_id, + ownerTeamId: row.owner_team_id, createdAt: row.created_at, updatedAt: row.updated_at, deletedAt: row.deleted_at, @@ -458,9 +469,7 @@ export class AutomationStore { * a storage invariant rather than a side effect of starting an invocation. */ async resolveCanonicalOwner(automation: AutomationRow): Promise { - if (automation.user_id || !automation.created_by || automation.created_by === "anonymous") { - return automation; - } + if (!needsCanonicalOwner(automation)) return automation; const identity = await new UserStore(this.db).getIdentity("github", automation.created_by); if (!identity) return automation; @@ -475,16 +484,68 @@ export class AutomationStore { return (await this.getById(automation.id)) ?? automation; } + /** + * Project the canonical owner that {@link resolveCanonicalOwner} would repair, for a page of + * rows in one bounded lookup, so collection capabilities agree with item admission. + */ + async projectCanonicalOwners(rows: readonly AutomationRow[]): Promise { + const legacyCreators = [ + ...new Set(rows.filter(needsCanonicalOwner).map((row) => row.created_by)), + ]; + const owners = new Map(); + for (let offset = 0; offset < legacyCreators.length; offset += MAX_D1_QUERY_PARAMETERS) { + const chunk = legacyCreators.slice(offset, offset + MAX_D1_QUERY_PARAMETERS); + const result = await this.db + .prepare( + `SELECT provider_user_id, user_id FROM user_identities + WHERE provider = 'github' AND provider_user_id IN (${chunk.map(() => "?").join(", ")})` + ) + .bind(...chunk) + .all<{ provider_user_id: string; user_id: string }>(); + for (const identity of result.results ?? []) { + owners.set(identity.provider_user_id, identity.user_id); + } + } + return rows.map((row) => { + const owner = needsCanonicalOwner(row) ? owners.get(row.created_by) : undefined; + return owner ? { ...row, user_id: owner } : row; + }); + } + async list(options: { limit: number; cursor?: CreatedAtCursor | null; nameSearch?: string; repoOwner?: string; repoName?: string; + viewer?: SessionViewer; + teamId?: string | null; }): Promise { const conditions: string[] = ["deleted_at IS NULL"]; const params: unknown[] = []; + const viewer = options.viewer; + if (viewer?.kind === "user") { + if (viewer.suspended || !viewer.permissions.includes("automations.read")) { + conditions.push("0 = 1"); + } else if (!isWorkspaceAdmin(viewer.roleKey)) { + // One bound parameter regardless of how many teams the viewer belongs to. + conditions.push( + `(owner_team_id IS NULL OR EXISTS ( + SELECT 1 FROM team_memberships m + WHERE m.team_id = automations.owner_team_id AND m.user_id = ?))` + ); + params.push(viewer.userId); + } + } else if (viewer?.kind === "service" && viewer.teamId !== null) { + conditions.push("(owner_team_id IS NULL OR owner_team_id = ?)"); + params.push(viewer.teamId); + } + if (options.teamId !== undefined) { + conditions.push("owner_team_id IS ?"); + params.push(options.teamId); + } + if (options.nameSearch) { conditions.push("name LIKE ? ESCAPE '\\' COLLATE NOCASE"); params.push(`%${escapeLikePattern(options.nameSearch)}%`); @@ -622,6 +683,51 @@ export class AutomationStore { return this.getById(id); } + /** + * Reassign the executor only if, at write time, the caller still holds reassignment authority + * and the candidate can still launch the automation's current targets. + */ + bindExecutorChange(automation: AutomationRow, userId: string, actorUserId: string): SqlStatement { + const manageOwn = rolePermissionPredicate("automations.manage.own"); + const manageAny = rolePermissionPredicate("automations.manage.any"); + const execution = automationExecutionPredicate({ + automationId: automation.id, + executionUserId: userId, + requiresRepositoryUse: "stored", + requiresEnvironmentUse: "stored", + }); + return this.db + .prepare( + `UPDATE automations SET user_id = ?, updated_at = ? + WHERE id = ? AND deleted_at IS NULL AND user_id IS ? AND owner_team_id IS ? + AND user_id IS NOT ? + AND EXISTS ( + SELECT 1 FROM users actor + JOIN user_role_assignments actor_role ON actor_role.user_id = actor.id + JOIN roles r ON r.id = actor_role.role_id + WHERE actor.id = ? AND actor.suspended_at IS NULL + AND (${manageOwn.sql} OR ${manageAny.sql}) + AND (r.key IN (${WORKSPACE_ADMIN_ROLE_KEYS.map(() => "?").join(", ")}) OR EXISTS ( + SELECT 1 FROM team_memberships lead_membership + WHERE lead_membership.team_id = automations.owner_team_id + AND lead_membership.user_id = actor.id AND lead_membership.role = 'lead'))) + AND ${execution.sql}` + ) + .bind( + userId, + Date.now(), + automation.id, + automation.user_id, + automation.owner_team_id, + userId, + actorUserId, + ...manageOwn.values, + ...manageAny.values, + ...WORKSPACE_ADMIN_ROLE_KEYS, + ...execution.values + ); + } + /** Build a soft-delete statement for composition in an atomic batch. */ bindSoftDelete(id: string, now = Date.now()): SqlStatement { return this.db @@ -1117,9 +1223,18 @@ export class AutomationStore { children: AutomationRunRow[]; overlapScope: InvocationOverlapScope; advanceSchedule?: ScheduleAdvance; + /** Team grants version the targets were authorized against; a change refuses admission. */ + teamGrantsVersion?: { teamId: string; version: number }; }): Promise<{ inserted: boolean }> { const invocation = params.invocation; const overlap = this.overlapPredicate(invocation.automation_id, params.overlapScope); + const grants = params.teamGrantsVersion; + const grantsGuard = grants + ? { + sql: "AND EXISTS (SELECT 1 FROM teams WHERE id = ? AND grants_version = ?)", + params: [grants.teamId, grants.version], + } + : { sql: "", params: [] }; const statements: SqlStatement[] = []; statements.push( this.db @@ -1128,7 +1243,7 @@ export class AutomationStore { (id, automation_id, source, scheduled_at, trigger_key, concurrency_key, trigger_metadata, skip_reason, failure_counted_at, created_at, updated_at) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? - WHERE NOT EXISTS (${overlap.sql})` + WHERE NOT EXISTS (${overlap.sql}) ${grantsGuard.sql}` ) .bind( invocation.id, @@ -1142,7 +1257,8 @@ export class AutomationStore { invocation.failure_counted_at, invocation.created_at, invocation.updated_at, - ...overlap.params + ...overlap.params, + ...grantsGuard.params ) ); @@ -1183,14 +1299,16 @@ export class AutomationStore { statements.push( this.db .prepare( + // A grants change leaves the slot due, so the next tick re-authorizes it. `UPDATE automations SET next_run_at = ?, updated_at = ? - WHERE id = ? AND deleted_at IS NULL AND next_run_at = ?` + WHERE id = ? AND deleted_at IS NULL AND next_run_at = ? ${grantsGuard.sql}` ) .bind( params.advanceSchedule.nextRunAt, Date.now(), invocation.automation_id, - params.advanceSchedule.fromSlot + params.advanceSchedule.fromSlot, + ...grantsGuard.params ) ); } diff --git a/packages/control-plane/src/db/environments.ts b/packages/control-plane/src/db/environments.ts index 7879e5d4d3..7c1f03409c 100644 --- a/packages/control-plane/src/db/environments.ts +++ b/packages/control-plane/src/db/environments.ts @@ -64,6 +64,7 @@ export function toEnvironment( ): Environment { return { id: row.id, + ownerTeamId: row.owner_team_id, name: row.name, description: row.description, prebuildEnabled: row.prebuild_enabled === 1, @@ -125,21 +126,26 @@ export class EnvironmentStore { } /** - * Look up an environment by name, case-insensitively (names are unique under - * lower(name)). Used to answer the uniqueness pre-check with a 409 before the - * insert would trip the unique index. + * Look up a case-insensitive name within its exact team or workspace scope. + * Used to answer the uniqueness pre-check before the insert trips the index. */ - async getByName(name: string): Promise { + async getByName(name: string, ownerTeamId: string | null): Promise { const row = await this.db - .prepare("SELECT * FROM environments WHERE lower(name) = lower(?)") - .bind(name) + .prepare("SELECT * FROM environments WHERE lower(name) = lower(?) AND owner_team_id IS ?") + .bind(name, ownerTeamId) .first(); return row ? withValidatedOwnerTeam(row) : null; } - async list(): Promise<{ environments: EnvironmentRow[]; total: number }> { + /** Every environment, or only those with this exact owner (null: workspace-owned). */ + async list( + ownerTeamId?: string | null + ): Promise<{ environments: EnvironmentRow[]; total: number }> { const result = await this.db - .prepare("SELECT * FROM environments ORDER BY created_at DESC") + .prepare( + `SELECT * FROM environments ${ownerTeamId === undefined ? "" : "WHERE owner_team_id IS ?"} ORDER BY created_at DESC` + ) + .bind(...(ownerTeamId === undefined ? [] : [ownerTeamId])) .all(); const environments = (result.results || []).map(withValidatedOwnerTeam); return { environments, total: environments.length }; diff --git a/packages/control-plane/src/db/session-index-batch.test.ts b/packages/control-plane/src/db/session-index-batch.test.ts new file mode 100644 index 0000000000..4a6d09a479 --- /dev/null +++ b/packages/control-plane/src/db/session-index-batch.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from "vitest"; +import { + MAX_AUTOMATION_INVOCATION_LIST_LIMIT, + MAX_AUTOMATION_REPOSITORIES, +} from "@open-inspect/shared/types/automations"; +import { emptyStatement, TEST_SESSION_ROW } from "../router.test-support"; +import { MAX_D1_QUERY_PARAMETERS } from "./query-limits"; +import { SessionIndexStore } from "./session-index"; +import type { SqlDatabase, SqlStatement } from "./sql-database"; + +function database(rows: ReadonlyMap = new Map()) { + const bindings: unknown[][] = []; + const prepare = vi.fn((sql: string) => { + let ids: unknown[] = []; + const statement: SqlStatement = { + ...emptyStatement(), + bind(...values) { + expect(values.length).toBeLessThanOrEqual(MAX_D1_QUERY_PARAMETERS); + expect(sql.match(/\?/g)).toHaveLength(values.length); + ids = values; + bindings.push(values); + return statement; + }, + all: async () => ({ + results: ids.flatMap((id) => (rows.has(String(id)) ? [rows.get(String(id)) as T] : [])), + meta: { changes: 0 }, + }), + }; + return statement; + }); + const db: SqlDatabase = { prepare, batch: vi.fn() }; + return { db, prepare, bindings }; +} + +describe("SessionIndexStore.getByIds", () => { + it("deduplicates a full run page and chunks reads within the D1 parameter limit", async () => { + const ids = Array.from( + { length: MAX_AUTOMATION_INVOCATION_LIST_LIMIT * MAX_AUTOMATION_REPOSITORIES }, + (_, i) => `session-${i}` + ); + const rows = new Map( + ids.slice(0, -1).map((id) => [ + id, + { + ...TEST_SESSION_ROW, + id, + user_id: "session-owner", + owner_team_id: "team_alpha", + visibility: "private", + }, + ]) + ); + const { db, prepare, bindings } = database(rows); + + const sessions = await new SessionIndexStore(db).getByIds([...ids, ...ids]); + + expect(prepare).toHaveBeenCalledTimes(Math.ceil(ids.length / MAX_D1_QUERY_PARAMETERS)); + expect(bindings.flat()).toEqual(ids); + expect(sessions.size).toBe(ids.length - 1); + expect(sessions.has(ids.at(-1)!)).toBe(false); + expect(sessions.get(ids[0])).toMatchObject({ + id: ids[0], + userId: "session-owner", + ownerTeamId: "team_alpha", + visibility: "private", + }); + }); + + it("does not query for an empty set of IDs", async () => { + const { db, prepare } = database(); + + expect(await new SessionIndexStore(db).getByIds([])).toEqual(new Map()); + expect(prepare).not.toHaveBeenCalled(); + }); + + it("rejects malformed persisted rows through the canonical session-row parser", async () => { + const { db } = database( + new Map([["session-1", { ...TEST_SESSION_ROW, visibility: "invalid" }]]) + ); + + await expect(new SessionIndexStore(db).getByIds(["session-1"])).rejects.toThrow( + "Malformed persisted session index row" + ); + }); +}); diff --git a/packages/control-plane/src/db/session-index.ts b/packages/control-plane/src/db/session-index.ts index beab2e1862..a4d20823d5 100644 --- a/packages/control-plane/src/db/session-index.ts +++ b/packages/control-plane/src/db/session-index.ts @@ -14,7 +14,7 @@ import { import type { SessionListRepository } from "@open-inspect/shared/types/repositories"; import type { SessionVisibility } from "@open-inspect/shared/types/teams"; import { visibleSessionsPredicate, type SessionReadScope } from "./session-visibility"; -import { assertD1QueryParameterLimit } from "./query-limits"; +import { assertD1QueryParameterLimit, MAX_D1_QUERY_PARAMETERS } from "./query-limits"; import type { TeamsEnforcementMode } from "../authorization/teams-enforcement"; import { sessionModelProviderAuthSchema, @@ -452,6 +452,23 @@ export class SessionIndexStore { return row ? toEntry(row) : null; } + async getByIds(sessionIds: readonly string[]): Promise> { + const result = new Map(); + const uniqueIds = [...new Set(sessionIds)]; + for (let offset = 0; offset < uniqueIds.length; offset += MAX_D1_QUERY_PARAMETERS) { + const ids = uniqueIds.slice(offset, offset + MAX_D1_QUERY_PARAMETERS); + const rows = await this.db + .prepare(`SELECT * FROM sessions WHERE id IN (${ids.map(() => "?").join(", ")})`) + .bind(...ids) + .all(); + for (const value of rows.results) { + const row = parseSessionRow(value); + if (row) result.set(row.id, toEntry(row)); + } + } + return result; + } + private async getProviderAuth(sessionId: string): Promise { const result = await this.db .prepare( diff --git a/packages/control-plane/src/db/session-repositories.ts b/packages/control-plane/src/db/session-repositories.ts new file mode 100644 index 0000000000..a687014051 --- /dev/null +++ b/packages/control-plane/src/db/session-repositories.ts @@ -0,0 +1,20 @@ +import { sessionRepositoryRowSchema } from "./session-list-metadata"; +import type { SqlDatabase } from "./sql-database"; + +/** Persisted session membership, independent of mutable environment provenance. */ +export class SessionRepositoryStore { + constructor(private readonly db: SqlDatabase) {} + + async listRepositoryIds( + sessionId: string + ): Promise> { + const rows = await this.db + .prepare("SELECT * FROM session_repositories WHERE session_id = ? ORDER BY position") + .bind(sessionId) + .all(); + return rows.results.map((value) => { + const row = sessionRepositoryRowSchema.parse(value); + return { repoOwner: row.repo_owner, repoName: row.repo_name, repoId: row.repo_id }; + }); + } +} diff --git a/packages/control-plane/src/db/session-visibility.ts b/packages/control-plane/src/db/session-visibility.ts index a4998f1a72..009b093c94 100644 --- a/packages/control-plane/src/db/session-visibility.ts +++ b/packages/control-plane/src/db/session-visibility.ts @@ -1,3 +1,4 @@ +import { isWorkspaceAdmin } from "@open-inspect/shared/rbac"; import type { SessionViewer } from "@open-inspect/shared"; import type { TeamsEnforcementMode } from "../authorization/teams-enforcement"; @@ -26,7 +27,7 @@ export function visibleSessionsPredicate( WHERE tm.team_id = ${alias}.owner_team_id AND tm.user_id = ?)) )` : `${alias}.visibility != 'private'`; const params: unknown[] = teamsEnforced - ? [viewer.roleKey === "owner" || viewer.roleKey === "administrator" ? 1 : 0, viewer.userId] + ? [isWorkspaceAdmin(viewer.roleKey) ? 1 : 0, viewer.userId] : []; if (options.excludePrivate) { return { diff --git a/packages/control-plane/src/db/team-audit.ts b/packages/control-plane/src/db/team-audit.ts index 876ec671e8..fe4140c217 100644 --- a/packages/control-plane/src/db/team-audit.ts +++ b/packages/control-plane/src/db/team-audit.ts @@ -1,6 +1,52 @@ import type { AuditOperationAction } from "@open-inspect/shared/types/audit-events"; import type { SqlDatabase, SqlStatement } from "./sql-database"; +/** One applied user operation on an owned resource. */ +export interface AppliedAuditEvent { + requestId: string; + actorUserId: string; + action: AuditOperationAction; + resourceType: "team" | "automation"; + resourceId: string; + teamId: string | null; + targetUserId?: string; + before: unknown; + after: unknown; +} + +/** + * Build the audit insert for an applied operation. With `onlyIfPreviousChanged`, the row is + * written only when the preceding statement in the same batch changed a row. + */ +export function bindAppliedAuditEvent( + db: SqlDatabase, + event: AppliedAuditEvent, + onlyIfPreviousChanged = false +): SqlStatement { + return db + .prepare( + `INSERT INTO authorization_audit_events + (id, occurred_at, request_id, principal_kind, actor_user_id_snapshot, + action, resource_type, resource_id, target_user_id_snapshot, team_id, + reason_code, operation_result, metadata_json) + SELECT ?, ?, ?, 'user', ?, ?, ?, ?, ?, ?, ?, 'applied', ? + ${onlyIfPreviousChanged ? "WHERE changes() = 1" : ""}` + ) + .bind( + crypto.randomUUID(), + Date.now(), + event.requestId, + event.actorUserId, + event.action, + event.resourceType, + event.resourceId, + event.targetUserId ?? null, + event.teamId, + event.action, + JSON.stringify({ before: event.before ?? {}, requested: {}, after: event.after ?? {} }) + ); +} + export interface TeamAuditInput { requestId: string; actorUserId: string; @@ -11,34 +57,16 @@ export interface TeamAuditInput { after: unknown; } +export type TeamAuditActor = Pick; + export class TeamAuditStore { constructor(private readonly db: SqlDatabase) {} bind(input: TeamAuditInput, onlyIfPreviousChanged = false): SqlStatement { - return this.db - .prepare( - `INSERT INTO authorization_audit_events - (id, occurred_at, request_id, principal_kind, actor_user_id_snapshot, - action, resource_type, resource_id, target_user_id_snapshot, team_id, - reason_code, operation_result, metadata_json) - SELECT ?, ?, ?, 'user', ?, ?, 'team', ?, ?, ?, ?, 'applied', ? - ${onlyIfPreviousChanged ? "WHERE changes() = 1" : ""}` - ) - .bind( - crypto.randomUUID(), - Date.now(), - input.requestId, - input.actorUserId, - input.action, - input.teamId, - input.targetUserId ?? null, - input.teamId, - input.action, - JSON.stringify({ before: input.before ?? {}, requested: {}, after: input.after ?? {} }) - ); - } - - async write(input: TeamAuditInput): Promise { - await this.bind(input).run(); + return bindAppliedAuditEvent( + this.db, + { ...input, resourceType: "team", resourceId: input.teamId }, + onlyIfPreviousChanged + ); } } diff --git a/packages/control-plane/src/db/team-memberships.ts b/packages/control-plane/src/db/team-memberships.ts index 13ffbe4bc2..37e0097249 100644 --- a/packages/control-plane/src/db/team-memberships.ts +++ b/packages/control-plane/src/db/team-memberships.ts @@ -6,7 +6,8 @@ import { type TeamMembership, type TeamRole, } from "@open-inspect/shared/types/teams"; -import type { SqlDatabase, SqlStatement } from "./sql-database"; +import { TeamAuditStore, type TeamAuditActor, type TeamAuditInput } from "./team-audit"; +import type { SqlDatabase, SqlResult, SqlStatement } from "./sql-database"; export class LastLeadError extends Error { constructor() { @@ -122,27 +123,51 @@ export class TeamMembershipStore { ); } + /** With `audit`, the insert and its `team.member_added` row commit or roll back together. */ async add( teamId: string, userId: string, role: TeamRole = "member", - source: TeamMembership["source"] = "manual" + source: TeamMembership["source"] = "manual", + audit?: TeamAuditActor ): Promise { - const result = await this.db - .prepare( - "INSERT INTO team_memberships (team_id, user_id, role, source, created_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING" - ) - .bind(teamId, userId, teamRoleSchema.parse(role), source, Date.now()) - .run(); + const createdAt = Date.now(); + const result = await this.runAudited( + this.db + .prepare( + "INSERT INTO team_memberships (team_id, user_id, role, source, created_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING" + ) + .bind(teamId, userId, teamRoleSchema.parse(role), source, createdAt), + audit && { + ...audit, + teamId, + targetUserId: userId, + action: "team.member_added", + before: {}, + after: { teamId, userId, role, source, createdAt }, + } + ); return result.meta.changes > 0; } - async addIfJoinable(teamId: string, userId: string): Promise { - const result = await this.bindAddIfJoinable(teamId, userId).run(); + /** With `audit`, the join and its `team.member_joined` row commit or roll back together. */ + async addIfJoinable(teamId: string, userId: string, audit?: TeamAuditActor): Promise { + const createdAt = Date.now(); + const result = await this.runAudited( + this.bindAddIfJoinable(teamId, userId, createdAt), + audit && { + ...audit, + teamId, + targetUserId: userId, + action: "team.member_joined", + before: {}, + after: { teamId, userId, role: "member", source: "manual", createdAt }, + } + ); return result.meta.changes > 0; } - private bindAddIfJoinable(teamId: string, userId: string): SqlStatement { + private bindAddIfJoinable(teamId: string, userId: string, createdAt: number): SqlStatement { return this.db .prepare( `INSERT INTO team_memberships (team_id, user_id, role, source, created_at) @@ -150,31 +175,69 @@ export class TeamMembershipStore { WHERE id = ? AND join_policy = 'open' AND archived_at IS NULL ON CONFLICT DO NOTHING` ) - .bind(userId, Date.now(), teamId); + .bind(userId, createdAt, teamId); } - async setRole(teamId: string, userId: string, role: TeamRole): Promise { - const result = await this.db - .prepare( - `UPDATE team_memberships SET role = ? WHERE team_id = ? AND user_id = ? + /** With `audit`, the change and its `team.member_role_changed` row commit or roll back together. */ + async setRole( + teamId: string, + userId: string, + role: TeamRole, + audit?: TeamAuditActor & { before: TeamMembership } + ): Promise { + const result = await this.runAudited( + this.db + .prepare( + `UPDATE team_memberships SET role = ? WHERE team_id = ? AND user_id = ? AND (? = 'lead' OR role != 'lead' OR (SELECT COUNT(*) FROM team_memberships WHERE team_id = ? AND role = 'lead') > 1)` - ) - .bind(teamRoleSchema.parse(role), teamId, userId, role, teamId) - .run(); + ) + .bind(teamRoleSchema.parse(role), teamId, userId, role, teamId), + audit && { + ...audit, + teamId, + targetUserId: userId, + action: "team.member_role_changed", + after: { ...audit.before, role }, + } + ); if (result.meta.changes === 0) await this.throwMembershipUpdateError(teamId, userId); } - async remove(teamId: string, userId: string): Promise { - const result = await this.db - .prepare( - `DELETE FROM team_memberships WHERE team_id = ? AND user_id = ? + /** With `audit`, the removal and its `team.member_removed` row commit or roll back together. */ + async remove( + teamId: string, + userId: string, + audit?: TeamAuditActor & { before: TeamMembership } + ): Promise { + const result = await this.runAudited( + this.db + .prepare( + `DELETE FROM team_memberships WHERE team_id = ? AND user_id = ? AND (role != 'lead' OR (SELECT COUNT(*) FROM team_memberships WHERE team_id = ? AND role = 'lead') > 1)` - ) - .bind(teamId, userId, teamId) - .run(); + ) + .bind(teamId, userId, teamId), + audit && { + ...audit, + teamId, + targetUserId: userId, + action: "team.member_removed", + after: {}, + } + ); if (result.meta.changes === 0) await this.throwMembershipUpdateError(teamId, userId); } + /** Runs `mutation`, audited in the same batch only when it changed a row. */ + private async runAudited( + mutation: SqlStatement, + audit: TeamAuditInput | undefined + ): Promise { + const [result] = await this.db.batch( + audit ? [mutation, new TeamAuditStore(this.db).bind(audit, true)] : [mutation] + ); + return result; + } + private async throwMembershipUpdateError(teamId: string, userId: string): Promise { const member = await this.db .prepare("SELECT 1 AS ok FROM team_memberships WHERE team_id = ? AND user_id = ?") diff --git a/packages/control-plane/src/db/team-repository-grants.test.ts b/packages/control-plane/src/db/team-repository-grants.test.ts new file mode 100644 index 0000000000..42b965c894 --- /dev/null +++ b/packages/control-plane/src/db/team-repository-grants.test.ts @@ -0,0 +1,166 @@ +import { DatabaseSync } from "node:sqlite"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { applyMigrations } from "../node/migrate"; +import { createNodeSqlDatabase, type NodeSqlDatabase } from "../node/sqlite-database"; +import { TeamStore } from "./teams"; +import { TeamRepositoryGrantStore } from "./team-repository-grants"; + +describe("team repository grant writes", () => { + let db: NodeSqlDatabase; + let store: TeamRepositoryGrantStore; + let teamId: string; + + beforeEach(async () => { + const sqlite = new DatabaseSync(":memory:"); + applyMigrations( + sqlite, + resolve(dirname(fileURLToPath(import.meta.url)), "../../../../terraform/d1/migrations") + ); + db = createNodeSqlDatabase(sqlite); + store = new TeamRepositoryGrantStore(db); + teamId = ( + await new TeamStore(db).create({ + slug: "engineering", + name: "Engineering", + joinPolicy: "invite_only", + }) + ).id; + }); + afterEach(() => db.close()); + + const named = (repoExternalId = 1) => ({ + kind: "repository" as const, + repoExternalId, + owner: "acme", + name: `repo-${repoExternalId}`, + }); + + it("adds display-ready grants and bumps the version only for actual writes", async () => { + const grant = await store.add(teamId, named()); + expect(grant).toMatchObject({ + teamId, + kind: "repository", + repoExternalId: 1, + owner: "acme", + name: "repo-1", + }); + expect((await new TeamStore(db).getById(teamId))?.grantsVersion).toBe(1); + expect(await store.add(teamId, named())).toEqual(grant); + expect((await new TeamStore(db).getById(teamId))?.grantsVersion).toBe(1); + expect(await store.remove(teamId, grant.id)).toBe(true); + expect((await new TeamStore(db).getById(teamId))?.grantsVersion).toBe(2); + expect(await store.remove(teamId, grant.id)).toBe(false); + expect((await new TeamStore(db).getById(teamId))?.grantsVersion).toBe(2); + }); + + it.each(["installation", "repository"] as const)( + "refuses mixing %s with the other grant kind", + async (kind) => { + await store.add(teamId, kind === "installation" ? { kind } : named()); + await expect( + store.add(teamId, kind === "installation" ? named() : { kind: "installation" }) + ).rejects.toThrow("Remove the existing grants before changing grant kind"); + expect((await new TeamStore(db).getById(teamId))?.grantsVersion).toBe(1); + } + ); + + it("refuses the 501st named grant, including concurrent additions at the cap", async () => { + await db.batch( + Array.from({ length: 499 }, (_, i) => + db + .prepare( + "INSERT INTO team_repository_grants (id, team_id, grant_kind, repo_external_id, repo_owner, repo_name, created_at) VALUES (?, ?, 'repository', ?, 'acme', ?, 1)" + ) + .bind(`grant-${i}`, teamId, i + 1, `repo-${i + 1}`) + ) + ); + const results = await Promise.allSettled([ + store.add(teamId, named(500)), + store.add(teamId, named(501)), + ]); + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); + expect(await store.listForTeam(teamId)).toHaveLength(500); + expect((await new TeamStore(db).getById(teamId))?.grantsVersion).toBe(1); + }); + + it("validates the discriminated request before writing", async () => { + await expect(store.add(teamId, { ...named(), repoExternalId: -1 })).rejects.toThrow(); + expect(await store.listForTeam(teamId)).toEqual([]); + expect((await new TeamStore(db).getById(teamId))?.grantsVersion).toBe(0); + }); + + it("does not delete another team's grant", async () => { + const grant = await store.add(teamId, named()); + const other = await new TeamStore(db).create({ + slug: "other", + name: "Other", + joinPolicy: "open", + }); + expect(await store.remove(other.id, grant.id)).toBe(false); + expect((await new TeamStore(db).getById(other.id))?.grantsVersion).toBe(0); + expect(await store.covers(teamId, [1])).toBe(true); + expect(await store.covers(teamId, [null])).toBe(false); + }); + + it("does not mutate grants on an archived team", async () => { + const grant = await store.add(teamId, named()); + await new TeamStore(db).archive(teamId); + expect(await store.remove(teamId, grant.id)).toBe(false); + await expect(store.add(teamId, named(2))).rejects.toThrow("Team is not active"); + expect(await store.covers(teamId, [1])).toBe(true); + expect((await new TeamStore(db).getById(teamId))?.grantsVersion).toBe(1); + }); + + it("covers unresolved repository rows only with an installation grant", async () => { + await store.add(teamId, { kind: "installation" }); + expect(await store.covers(teamId, [null, 2])).toBe(true); + }); + + it("leaves repositories workspace-level when no grant names them", async () => { + expect(await store.listTeamsForRepository(1)).toEqual([]); + await store.add(teamId, named(2)); + expect(await store.listTeamsForRepository(1)).toEqual([]); + expect(await store.listTeamsForRepository(2)).toEqual([teamId]); + }); + + it("finds every granting team by numeric identity, including installation grants", async () => { + const other = await new TeamStore(db).create({ + slug: "other", + name: "Other", + joinPolicy: "open", + }); + await store.add(teamId, named(1)); + await store.add(other.id, { kind: "installation" }); + expect(await store.listTeamsForRepository(1)).toEqual([other.id, teamId].sort()); + expect(await store.listTeamsForRepository(999)).toEqual([other.id]); + }); + + it("does not turn a retained archived-team grant into workspace-wide access", async () => { + await store.add(teamId, named(1)); + await new TeamStore(db).archive(teamId); + expect(await store.listTeamsForRepository(1)).toEqual([teamId]); + }); + + it("rolls mutation, version and domain audit back together", async () => { + const failingStore = new TeamRepositoryGrantStore({ + prepare: (sql) => db.prepare(sql), + batch: (statements) => + db.batch([...statements, db.prepare("INSERT INTO missing_table VALUES (1)")]), + }); + await expect( + failingStore.add(teamId, named(), { actorUserId: "actor", requestId: "request" }) + ).rejects.toThrow(); + expect(await store.listForTeam(teamId)).toEqual([]); + expect((await new TeamStore(db).getById(teamId))?.grantsVersion).toBe(0); + expect( + await db + .prepare( + "SELECT COUNT(*) AS count FROM authorization_audit_events WHERE action = 'team.grant_added'" + ) + .first() + ).toEqual({ count: 0 }); + }); +}); diff --git a/packages/control-plane/src/db/team-repository-grants.ts b/packages/control-plane/src/db/team-repository-grants.ts index 4d2fe2b0b1..c2df127554 100644 --- a/packages/control-plane/src/db/team-repository-grants.ts +++ b/packages/control-plane/src/db/team-repository-grants.ts @@ -1,4 +1,13 @@ import { z } from "zod"; +import { + addTeamRepositoryGrantRequestSchema, + MAX_TEAM_REPOSITORY_GRANTS, + teamRepositoryGrantSchema, + type AddTeamRepositoryGrantRequest, + type TeamRepositoryGrant, +} from "@open-inspect/shared/types/teams"; +import { generateId } from "../auth/crypto"; +import { TeamAuditStore } from "./team-audit"; import type { SqlDatabase } from "./sql-database"; const grantSchema = z.object({ @@ -6,6 +15,44 @@ const grantSchema = z.object({ repo_external_id: z.number().nullable(), }); +type TeamRepositoryGrantRow = z.infer; + +/** The subset of `repoIds` a team's grants cover; an installation grant covers every id. */ +export function coveredRepositoryIds( + grants: readonly TeamRepositoryGrantRow[], + repoIds: readonly T[] +): T[] { + if (grants.some((grant) => grant.grant_kind === "installation")) return [...repoIds]; + const allowed = new Set(grants.map((grant) => grant.repo_external_id)); + return repoIds.filter((id) => id !== null && allowed.has(id)); +} + +export class TeamRepositoryGrantConflictError extends Error { + constructor(readonly code: "grant_kind_conflict" | "repository_grant_limit" | "team_not_active") { + super( + code === "grant_kind_conflict" + ? "Remove the existing grants before changing grant kind" + : code === "repository_grant_limit" + ? "Team repository grant limit reached" + : "Team is not active" + ); + } +} + +type GrantActor = { actorUserId: string; requestId: string }; + +function toGrant(row: Record): TeamRepositoryGrant { + return teamRepositoryGrantSchema.parse({ + id: row.id, + teamId: row.team_id, + kind: row.grant_kind, + repoExternalId: row.repo_external_id, + owner: row.repo_owner, + name: row.repo_name, + createdAt: row.created_at, + }); +} + export class TeamRepositoryGrantStore { constructor(private readonly db: SqlDatabase) {} @@ -17,11 +64,131 @@ export class TeamRepositoryGrantStore { return z.array(grantSchema).parse(rows.results); } + async listDetailsForTeam(teamId: string): Promise { + const rows = await this.db + .prepare("SELECT * FROM team_repository_grants WHERE team_id = ? ORDER BY created_at, id") + .bind(teamId) + .all(); + return rows.results.map(toGrant); + } + + async listTeamsForRepository(repoId: number): Promise { + const rows = await this.db + .prepare( + `SELECT DISTINCT team_id FROM team_repository_grants + WHERE grant_kind = 'installation' OR (grant_kind = 'repository' AND repo_external_id = ?) + ORDER BY team_id` + ) + .bind(repoId) + .all(); + return z + .array(z.object({ team_id: z.string() })) + .parse(rows.results) + .map((row) => row.team_id); + } + + async add( + teamId: string, + input: AddTeamRepositoryGrantRequest, + actor?: GrantActor + ): Promise { + const body = addTeamRepositoryGrantRequestSchema.parse(input); + const grant = teamRepositoryGrantSchema.parse({ + id: `grant_${generateId()}`, + teamId, + kind: body.kind, + repoExternalId: body.kind === "repository" ? body.repoExternalId : null, + owner: body.kind === "repository" ? body.owner : null, + name: body.kind === "repository" ? body.name : null, + createdAt: Date.now(), + }); + const statements = [ + this.db + .prepare( + `INSERT INTO team_repository_grants + (id, team_id, grant_kind, repo_external_id, repo_owner, repo_name, created_at) + SELECT ?, id, ?, ?, ?, ?, ? FROM teams + WHERE id = ? AND archived_at IS NULL + AND NOT EXISTS (SELECT 1 FROM team_repository_grants WHERE team_id = ? AND grant_kind != ?) + AND (SELECT COUNT(*) FROM team_repository_grants WHERE team_id = ?) < ? + ON CONFLICT DO NOTHING` + ) + .bind( + grant.id, + grant.kind, + grant.repoExternalId, + grant.owner, + grant.name, + grant.createdAt, + teamId, + teamId, + body.kind, + teamId, + MAX_TEAM_REPOSITORY_GRANTS + ), + this.db + .prepare( + "UPDATE teams SET grants_version = grants_version + 1, updated_at = ? WHERE id = ? AND changes() = 1" + ) + .bind(grant.createdAt, teamId), + ]; + if (actor) { + statements.push( + new TeamAuditStore(this.db).bind( + { ...actor, teamId, action: "team.grant_added", before: {}, after: grant }, + true + ) + ); + } + const [inserted] = await this.db.batch(statements); + if (inserted.meta.changes === 1) return grant; + + const grants = await this.listDetailsForTeam(teamId); + const existing = grants.find( + (row) => row.kind === body.kind && row.repoExternalId === grant.repoExternalId + ); + if (existing) return existing; + if (grants.some((row) => row.kind !== body.kind)) + throw new TeamRepositoryGrantConflictError("grant_kind_conflict"); + if (grants.length >= MAX_TEAM_REPOSITORY_GRANTS) + throw new TeamRepositoryGrantConflictError("repository_grant_limit"); + throw new TeamRepositoryGrantConflictError("team_not_active"); + } + + async remove(teamId: string, grantId: string, actor?: GrantActor): Promise { + const row = await this.db + .prepare("SELECT * FROM team_repository_grants WHERE team_id = ? AND id = ?") + .bind(teamId, grantId) + .first(); + if (!row) return false; + const before = toGrant(row); + const statements = [ + this.db + .prepare( + `DELETE FROM team_repository_grants WHERE team_id = ? AND id = ? + AND EXISTS (SELECT 1 FROM teams WHERE id = ? AND archived_at IS NULL)` + ) + .bind(teamId, grantId, teamId), + this.db + .prepare( + "UPDATE teams SET grants_version = grants_version + 1, updated_at = ? WHERE id = ? AND changes() = 1" + ) + .bind(Date.now(), teamId), + ]; + if (actor) + statements.push( + new TeamAuditStore(this.db).bind( + { ...actor, teamId, action: "team.grant_removed", before, after: {} }, + true + ) + ); + const [deleted] = await this.db.batch(statements); + return deleted.meta.changes === 1; + } + async covers(teamId: string, repoIds: readonly (number | null)[]): Promise { if (repoIds.length === 0) return true; const grants = await this.listForTeam(teamId); - if (grants.some((grant) => grant.grant_kind === "installation")) return true; - const allowed = new Set(grants.map((grant) => grant.repo_external_id)); - return repoIds.every((id) => id !== null && allowed.has(id)); + return coveredRepositoryIds(grants, repoIds).length === repoIds.length; } } diff --git a/packages/control-plane/src/db/teams.ts b/packages/control-plane/src/db/teams.ts index 6253ee4f40..b1255de81e 100644 --- a/packages/control-plane/src/db/teams.ts +++ b/packages/control-plane/src/db/teams.ts @@ -6,7 +6,7 @@ import { } from "@open-inspect/shared/types/teams"; import { generateId } from "../auth/crypto"; import { isUniqueConstraintError } from "./errors"; -import { TeamAuditStore } from "./team-audit"; +import { TeamAuditStore, type TeamAuditActor } from "./team-audit"; import type { SqlDatabase, SqlStatement } from "./sql-database"; export class TeamSlugConflictError extends Error { @@ -16,6 +16,9 @@ export class TeamSlugConflictError extends Error { } } +/** Who changed the team and the state they changed it from. */ +type TeamChangeAudit = TeamAuditActor & { before: Team }; + function rethrowTeamWriteError(cause: unknown): never { if (isUniqueConstraintError(cause)) throw new TeamSlugConflictError(); throw cause; @@ -46,6 +49,15 @@ export class TeamStore { return row ? toTeam(row) : null; } + async isActive(id: string): Promise { + return ( + (await this.db + .prepare("SELECT 1 AS ok FROM teams WHERE id = ? AND archived_at IS NULL") + .bind(id) + .first()) !== null + ); + } + async getBySlug(slug: string): Promise { const row = await this.db.prepare("SELECT * FROM teams WHERE slug = ?").bind(slug).first(); return row ? toTeam(row) : null; @@ -137,6 +149,7 @@ export class TeamStore { return (await this.getById(id))!; } + /** With `audit`, the change and its `team.updated` row commit or roll back together. */ async update( id: string, fields: { @@ -146,7 +159,8 @@ export class TeamStore { joinPolicy?: TeamJoinPolicy; defaultVisibility?: SessionVisibility; defaultEnvironmentId?: string | null; - } + }, + audit?: TeamChangeAudit ): Promise { if (fields.defaultEnvironmentId !== undefined && fields.defaultEnvironmentId !== null) { const environment = await this.db @@ -165,13 +179,30 @@ export class TeamStore { }; const entries = Object.entries(columns).filter((entry) => entry[1] !== undefined); if (entries.length) { - try { - await this.db + const now = Date.now(); + const statements = [ + this.db .prepare( `UPDATE teams SET ${entries.map(([key]) => `${key} = ?`).join(", ")}, updated_at = ? WHERE id = ?` ) - .bind(...entries.map((entry) => entry[1]), Date.now(), id) - .run(); + .bind(...entries.map((entry) => entry[1]), now, id), + ]; + if (audit) { + const changed = Object.entries(fields).filter((entry) => entry[1] !== undefined); + statements.push( + new TeamAuditStore(this.db).bind( + { + ...audit, + teamId: id, + action: "team.updated", + after: { ...audit.before, ...Object.fromEntries(changed), updatedAt: now }, + }, + true + ) + ); + } + try { + await this.db.batch(statements); } catch (cause) { rethrowTeamWriteError(cause); } @@ -179,22 +210,43 @@ export class TeamStore { return this.getById(id); } - async archive(id: string): Promise { - return await this.setArchived(id, Date.now()); + async archive(id: string, audit?: TeamChangeAudit): Promise { + return await this.setArchived(id, Date.now(), audit); } - async restore(id: string): Promise { - return await this.setArchived(id, null); + async restore(id: string, audit?: TeamChangeAudit): Promise { + return await this.setArchived(id, null, audit); } - private async setArchived(id: string, archivedAt: number | null): Promise { - const result = await this.db - .prepare( - `UPDATE teams SET archived_at = ?, updated_at = ? + /** With `audit`, the change and its `team.archived`/`team.restored` row commit or roll back together. */ + private async setArchived( + id: string, + archivedAt: number | null, + audit?: TeamChangeAudit + ): Promise { + const now = archivedAt ?? Date.now(); + const statements = [ + this.db + .prepare( + `UPDATE teams SET archived_at = ?, updated_at = ? WHERE id = ? AND ${archivedAt === null ? "archived_at IS NOT NULL" : "archived_at IS NULL"}` - ) - .bind(archivedAt, Date.now(), id) - .run(); + ) + .bind(archivedAt, now, id), + ]; + if (audit) { + statements.push( + new TeamAuditStore(this.db).bind( + { + ...audit, + teamId: id, + action: archivedAt === null ? "team.restored" : "team.archived", + after: { ...audit.before, archivedAt, updatedAt: now }, + }, + true + ) + ); + } + const [result] = await this.db.batch(statements); return (result.meta.changes ?? 0) > 0; } diff --git a/packages/control-plane/src/http/request-context.ts b/packages/control-plane/src/http/request-context.ts index 8218c7bb62..d3e3e9528c 100644 --- a/packages/control-plane/src/http/request-context.ts +++ b/packages/control-plane/src/http/request-context.ts @@ -7,6 +7,7 @@ import type { AuthenticationContext, Principal } from "../auth/principal"; import type { AuthenticationRequestServices } from "../auth/request-services"; import type { UserAuthRuntime } from "../auth/user/runtime"; import type { AutomationRow } from "../db/automation-store"; +import type { EnvironmentAdmission } from "../authorization/owned-resource-admission"; import type { SessionEntry } from "../db/session-index"; import type { RequestMetrics } from "../db/instrumented-sql-database"; import type { BackgroundTasks } from "../platform-ports"; @@ -15,6 +16,7 @@ import type { TeamsEnforcementMode } from "../authorization/teams-enforcement"; /** Automation resource admitted for the current mutation. */ export interface AutomationRouteAdmission { automation: AutomationRow; + viewer: SessionViewer; } /** @@ -30,6 +32,8 @@ export type RequestContext = AuthenticationRequestServices & { authentication?: AuthenticationContext; authorization?: EffectiveAuthorization; automationAdmission?: AutomationRouteAdmission; + /** Written only by route admission; read via `admittedEnvironment`. */ + environmentAdmission?: EnvironmentAdmission; teamAdmission?: { team: Team; access: TeamCapabilities }; sessionAdmission?: { row: SessionEntry & SessionAccessRow; viewer: SessionViewer }; childSessionAdmission?: { row: SessionEntry & SessionAccessRow; viewer: SessionViewer }; diff --git a/packages/control-plane/src/image-builds/credential-scope.ts b/packages/control-plane/src/image-builds/credential-scope.ts new file mode 100644 index 0000000000..441225e887 --- /dev/null +++ b/packages/control-plane/src/image-builds/credential-scope.ts @@ -0,0 +1,59 @@ +import type { InstallationRepository } from "@open-inspect/shared/types/repository-catalog"; +import { EnvironmentStore } from "../db/environments"; +import type { SqlDatabase } from "../db/sql-database"; +import { + repositoryCredentialScope, + type CredentialScope, +} from "../source-control/credential-scope"; +import { resolveRepositoryCredentialScope } from "../source-control/repository-scope"; +import { ImageBuildPlanningError, ImageBuildScopeNotFoundError } from "./errors"; +import type { ImageBuildScope } from "./model"; +import { repositoryIdentityKey } from "./provenance"; +import type { ResolvedImageBuildTarget } from "./scope"; + +/** Credentials cover only planned repositories, intersected with the environment owner's grants. */ +export async function resolveImageBuildTokenScope( + db: SqlDatabase, + scope: ImageBuildScope, + target: ResolvedImageBuildTarget, + loadCatalog: () => Promise +): Promise { + if (scope.kind !== target.kind) { + throw new ImageBuildPlanningError("Image build scope and target kinds do not match"); + } + + switch (target.kind) { + case "environment": { + const store = new EnvironmentStore(db); + const environment = await store.getById(scope.id); + if (!environment) throw new ImageBuildScopeNotFoundError(scope.kind, scope.id); + const rows = await store.getRepositoriesForEnvironment(scope.id); + const repositories = rows.map((row) => ({ + repoOwner: row.repo_owner, + repoName: row.repo_name, + repoId: row.repo_id, + })); + const currentIdentities = repositories.map(repositoryIdentityKey).sort(); + const plannedIdentities = target.repositories.map(repositoryIdentityKey).sort(); + // An edit after target resolution must not put unplanned repositories in the token. + if ( + currentIdentities.length !== plannedIdentities.length || + currentIdentities.some((identity, index) => identity !== plannedIdentities[index]) + ) { + throw new ImageBuildPlanningError(`Environment repositories changed: ${scope.id}`); + } + return await resolveRepositoryCredentialScope( + db, + repositories, + environment.owner_team_id, + loadCatalog + ); + } + case "repo": { + if (target.repositories.length !== 1) { + throw new ImageBuildPlanningError("Repository image build must contain one repository"); + } + return repositoryCredentialScope([target.repoId]); + } + } +} diff --git a/packages/control-plane/src/image-builds/daytona-adapter.test.ts b/packages/control-plane/src/image-builds/daytona-adapter.test.ts index ef1ee7a321..5576bcc437 100644 --- a/packages/control-plane/src/image-builds/daytona-adapter.test.ts +++ b/packages/control-plane/src/image-builds/daytona-adapter.test.ts @@ -61,7 +61,7 @@ function plan(overrides: Partial = {}): ImageBuildPlan { buildTimeoutMs: 1_800_000, correlation, callbackToken: "a".repeat(64), - cloneAuth: { type: "credential_helper", host: "github.com", username: "x", token: "clone-1" }, + cloneAuth: { type: "credential_helper", token: "clone-1" }, ...overrides, }; } diff --git a/packages/control-plane/src/image-builds/e2b-adapter.test.ts b/packages/control-plane/src/image-builds/e2b-adapter.test.ts index 7077cca13f..96afcea9e3 100644 --- a/packages/control-plane/src/image-builds/e2b-adapter.test.ts +++ b/packages/control-plane/src/image-builds/e2b-adapter.test.ts @@ -31,8 +31,6 @@ function createPlan(): ImageBuildPlan { callbackToken: "callback-token", cloneAuth: { type: "credential_helper", - host: "github.com", - username: "x-access-token", token: "clone-token", }, buildTimeoutMs: 1_800_001, diff --git a/packages/control-plane/src/image-builds/modal-adapter.test.ts b/packages/control-plane/src/image-builds/modal-adapter.test.ts index ddd0140040..cbe4c94f8d 100644 --- a/packages/control-plane/src/image-builds/modal-adapter.test.ts +++ b/packages/control-plane/src/image-builds/modal-adapter.test.ts @@ -27,8 +27,6 @@ function createPlan(): ImageBuildPlan { callbackToken: "modal-callback-token", cloneAuth: { type: "credential_helper", - host: "gitlab.com", - username: "oauth2", token: "clone-token", }, buildTimeoutMs: 1_800_000, @@ -59,8 +57,6 @@ describe("ModalImageBuildAdapter", () => { buildId: "build-1", repositories: [{ repoOwner: "acme", repoName: "repo", baseBranch: "develop" }], cloneToken: "clone-token", - cloneHost: "gitlab.com", - cloneUsername: "oauth2", buildExecutionTimeoutSeconds: 1800, providerSessionTimeoutSeconds: 2400, userEnvVars: { FOO: "bar" }, diff --git a/packages/control-plane/src/image-builds/modal-adapter.ts b/packages/control-plane/src/image-builds/modal-adapter.ts index fabdf4b0b1..5dcfb44e3b 100644 --- a/packages/control-plane/src/image-builds/modal-adapter.ts +++ b/packages/control-plane/src/image-builds/modal-adapter.ts @@ -27,9 +27,6 @@ export class ModalImageBuildAdapter implements ImageBuildAdapter { buildId: plan.buildId, repositories: plan.repositories, cloneToken: plan.cloneAuth.type === "credential_helper" ? plan.cloneAuth.token : undefined, - cloneHost: plan.cloneAuth.type === "credential_helper" ? plan.cloneAuth.host : undefined, - cloneUsername: - plan.cloneAuth.type === "credential_helper" ? plan.cloneAuth.username : undefined, userEnvVars: plan.userEnvVars, buildExecutionTimeoutSeconds: Math.ceil(plan.buildTimeoutMs / 1000), providerSessionTimeoutSeconds: resolveImageBuildProviderSessionTimeoutSeconds( diff --git a/packages/control-plane/src/image-builds/opencomputer-adapter.test.ts b/packages/control-plane/src/image-builds/opencomputer-adapter.test.ts index 2e7b840d97..37e2bd739f 100644 --- a/packages/control-plane/src/image-builds/opencomputer-adapter.test.ts +++ b/packages/control-plane/src/image-builds/opencomputer-adapter.test.ts @@ -24,8 +24,6 @@ function createPlan(): ImageBuildPlan { callbackToken: "callback-token", cloneAuth: { type: "credential_helper", - host: "github.com", - username: "x-access-token", token: "clone-token", }, buildTimeoutMs: 1_800_001, diff --git a/packages/control-plane/src/image-builds/planner.test.ts b/packages/control-plane/src/image-builds/planner.test.ts new file mode 100644 index 0000000000..a444ab2475 --- /dev/null +++ b/packages/control-plane/src/image-builds/planner.test.ts @@ -0,0 +1,280 @@ +import type { InstallationRepository } from "@open-inspect/shared/types/repository-catalog"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CredentialScope } from "../source-control/credential-scope"; +import { + EnvironmentStore, + type EnvironmentRepositoryRow, + type EnvironmentRow, +} from "../db/environments"; +import type { SqlDatabase } from "../db/sql-database"; +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import { readCachedInstallationRepositories } from "../repos/cache"; +import type * as ReposCacheModule from "../repos/cache"; +import { createTestEnv } from "../router.test-support"; +import type * as SourceControlModule from "../source-control"; +import { resolveImageBuildTokenScope } from "./credential-scope"; +import { ImageBuildPlanningError, ImageBuildScopeNotFoundError } from "./errors"; +import type { ImageBuildScope } from "./model"; +import { + ImageBuildPlanner, + type ImageBuildPlanRequest, + type ResolvedImageBuildTarget, +} from "./planner"; +import type * as ScopeModule from "./scope"; + +const scmProvider = vi.hoisted(() => ({ + generateCredentialHelperAuth: vi.fn(async (_scope: CredentialScope) => ({ + username: "x-access-token", + password: "clone-token", + })), +})); + +vi.mock("../source-control", async (importOriginal) => ({ + ...(await importOriginal()), + createSourceControlProviderFromEnv: vi.fn(() => scmProvider), +})); + +vi.mock("./scope", async (importOriginal) => ({ + ...(await importOriginal()), + resolveScopeSandboxSettings: vi.fn(async () => ({})), + loadScopeBuildSecrets: vi.fn(async () => undefined), +})); + +vi.mock("../repos/cache", async (importOriginal) => ({ + ...(await importOriginal()), + readCachedInstallationRepositories: vi.fn(), +})); + +const db = {} as SqlDatabase; +const REPO_SCOPE: ImageBuildScope = { kind: "repo", id: "acme/web" }; +const ENV_SCOPE: ImageBuildScope = { kind: "environment", id: "env_1" }; +const REPO_TARGET: ResolvedImageBuildTarget = { + kind: "repo", + repoId: 12, + repositories: [{ repoOwner: "acme", repoName: "web", baseBranch: "main" }], + repositoriesFingerprint: "fp-repo", +}; +const ENV_TARGET: ResolvedImageBuildTarget = { + kind: "environment", + repositories: [ + ...REPO_TARGET.repositories, + { repoOwner: "acme", repoName: "api", baseBranch: "develop" }, + ], + repositoriesFingerprint: "fp-env", +}; +const ENVIRONMENT: EnvironmentRow = { + id: ENV_SCOPE.id, + name: "Environment", + description: null, + prebuild_enabled: 1, + channel_associations: null, + owner_team_id: null, + created_at: 1, + updated_at: 1, +}; +const ENV_REPOSITORIES: EnvironmentRepositoryRow[] = ENV_TARGET.repositories.map( + (repository, position) => ({ + environment_id: ENV_SCOPE.id, + position, + repo_owner: repository.repoOwner, + repo_name: repository.repoName, + repo_id: position === 0 ? 12 : 30, + base_branch: repository.baseBranch, + }) +); +const CATALOG: InstallationRepository[] = [ + { id: 12, name: "web" }, + { id: 30, name: "api" }, + { id: 99, name: "sibling" }, +].map((repository) => ({ + ...repository, + owner: "acme", + fullName: `acme/${repository.name}`, + description: null, + private: true, + defaultBranch: "main", + archived: false, +})); +const loadCatalog = vi.fn<() => Promise>(); + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([]); + vi.spyOn(EnvironmentStore.prototype, "getById").mockResolvedValue(null); + vi.spyOn(EnvironmentStore.prototype, "getRepositoriesForEnvironment").mockResolvedValue([]); + vi.mocked(readCachedInstallationRepositories).mockResolvedValue(CATALOG); + scmProvider.generateCredentialHelperAuth.mockResolvedValue({ + username: "x-access-token", + password: "clone-token", + }); +}); + +afterEach(() => vi.restoreAllMocks()); + +function mockEnvironment( + ownerTeamId: string | null = null, + repositories: EnvironmentRepositoryRow[] = ENV_REPOSITORIES +): void { + vi.mocked(EnvironmentStore.prototype.getById).mockResolvedValue({ + ...ENVIRONMENT, + owner_team_id: ownerTeamId, + }); + vi.mocked(EnvironmentStore.prototype.getRepositoriesForEnvironment).mockResolvedValue( + repositories + ); +} + +function planRequest( + scope: ImageBuildScope, + target: ResolvedImageBuildTarget +): ImageBuildPlanRequest { + return { + buildId: "build-1", + scope, + target, + callbackUrl: "https://worker.test/image-builds/build-complete", + failureCallbackUrl: "https://worker.test/image-builds/build-failed", + correlation: { request_id: "request-1", trace_id: "trace-1" }, + callbackAuth: { token: "callback-token", tokenHash: "callback-hash", expiresAt: 1000 }, + }; +} + +describe("resolveImageBuildTokenScope", () => { + it("scopes environments to their stored member ids", async () => { + mockEnvironment(); + + expect(await resolveImageBuildTokenScope(db, ENV_SCOPE, ENV_TARGET, loadCatalog)).toEqual({ + kind: "repositories", + repositoryIds: [12, 30], + }); + }); + + it("intersects environment members with the owning team's grants", async () => { + mockEnvironment("team_a"); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 12 }, + { grant_kind: "repository", repo_external_id: 99 }, + ]); + + expect(await resolveImageBuildTokenScope(db, ENV_SCOPE, ENV_TARGET, loadCatalog)).toEqual({ + kind: "repositories", + repositoryIds: [12], + }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).toHaveBeenCalledExactlyOnceWith( + "team_a" + ); + }); + + it("accepts unchanged identities when member order or case differs", async () => { + mockEnvironment( + null, + [...ENV_REPOSITORIES].reverse().map((row) => ({ + ...row, + repo_owner: row.repo_owner.toUpperCase(), + repo_name: row.repo_name.toUpperCase(), + })) + ); + + expect(await resolveImageBuildTokenScope(db, ENV_SCOPE, ENV_TARGET, loadCatalog)).toEqual({ + kind: "repositories", + repositoryIds: [12, 30], + }); + }); + + it.each([ + { name: "added", rows: [...ENV_REPOSITORIES, { ...ENV_REPOSITORIES[0], repo_name: "extra" }] }, + { name: "removed", rows: ENV_REPOSITORIES.slice(0, 1) }, + { + name: "replaced", + rows: [ENV_REPOSITORIES[0], { ...ENV_REPOSITORIES[1], repo_name: "sibling", repo_id: null }], + }, + ])("fails closed when environment membership is $name", async ({ rows }) => { + mockEnvironment("team_a", rows); + + await expect( + resolveImageBuildTokenScope(db, ENV_SCOPE, ENV_TARGET, loadCatalog) + ).rejects.toBeInstanceOf(ImageBuildPlanningError); + }); + + it("fails closed when the environment no longer exists", async () => { + await expect( + resolveImageBuildTokenScope(db, ENV_SCOPE, ENV_TARGET, loadCatalog) + ).rejects.toBeInstanceOf(ImageBuildScopeNotFoundError); + }); + + it.each([{ repositories: [] }, { repositories: ENV_TARGET.repositories }])( + "rejects repo targets without exactly one repository", + async ({ repositories }) => { + await expect( + resolveImageBuildTokenScope(db, REPO_SCOPE, { ...REPO_TARGET, repositories }, loadCatalog) + ).rejects.toBeInstanceOf(ImageBuildPlanningError); + } + ); + + it.each([ + { scope: REPO_SCOPE, target: ENV_TARGET }, + { scope: ENV_SCOPE, target: REPO_TARGET }, + ])("rejects scope/target kind mismatches before reading stores", async ({ scope, target }) => { + await expect( + resolveImageBuildTokenScope(db, scope, target, loadCatalog) + ).rejects.toBeInstanceOf(ImageBuildPlanningError); + expect(EnvironmentStore.prototype.getById).not.toHaveBeenCalled(); + }); +}); + +describe("ImageBuildPlanner clone auth", () => { + it("mints a repository build token for that repository alone", async () => { + const plan = await new ImageBuildPlanner(createTestEnv(), db).planBuild( + planRequest(REPO_SCOPE, REPO_TARGET) + ); + + expect(scmProvider.generateCredentialHelperAuth).toHaveBeenCalledExactlyOnceWith({ + kind: "repositories", + repositoryIds: [12], + }); + expect(plan.cloneAuth).toEqual({ type: "credential_helper", token: "clone-token" }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + }); + + it("resolves NULL environment member ids from the cached catalog", async () => { + mockEnvironment( + null, + ENV_REPOSITORIES.map((row) => ({ ...row, repo_id: null })) + ); + const env = createTestEnv(); + + const plan = await new ImageBuildPlanner(env, db).planBuild(planRequest(ENV_SCOPE, ENV_TARGET)); + + expect(readCachedInstallationRepositories).toHaveBeenCalledExactlyOnceWith(env); + expect(scmProvider.generateCredentialHelperAuth).toHaveBeenCalledExactlyOnceWith({ + kind: "repositories", + repositoryIds: [12, 30], + }); + expect(plan.cloneAuth.type).toBe("credential_helper"); + }); + + it("does not mint a token for changed environment membership", async () => { + mockEnvironment(null, [{ ...ENV_REPOSITORIES[0], repo_name: "sibling", repo_id: null }]); + + const plan = await new ImageBuildPlanner(createTestEnv(), db).planBuild( + planRequest(ENV_SCOPE, ENV_TARGET) + ); + + expect(plan.cloneAuth).toEqual({ type: "unavailable" }); + expect(scmProvider.generateCredentialHelperAuth).not.toHaveBeenCalled(); + }); + + it("does not retry a scoped credential failure with broader auth", async () => { + scmProvider.generateCredentialHelperAuth.mockRejectedValueOnce(new Error("Token scope denied")); + + const plan = await new ImageBuildPlanner(createTestEnv(), db).planBuild( + planRequest(REPO_SCOPE, REPO_TARGET) + ); + + expect(plan.cloneAuth).toEqual({ type: "unavailable" }); + expect(scmProvider.generateCredentialHelperAuth).toHaveBeenCalledExactlyOnceWith({ + kind: "repositories", + repositoryIds: [12], + }); + }); +}); diff --git a/packages/control-plane/src/image-builds/planner.ts b/packages/control-plane/src/image-builds/planner.ts index 9a61f7fa8d..c82163c22b 100644 --- a/packages/control-plane/src/image-builds/planner.ts +++ b/packages/control-plane/src/image-builds/planner.ts @@ -1,7 +1,7 @@ import { resolveBuildTimeoutSeconds } from "@open-inspect/shared/types/integrations"; import { createLogger, type CorrelationContext } from "../logger"; -import { createSourceControlProviderFromEnv, resolveScmProviderFromEnv } from "../source-control"; -import { scmCloneIdentity } from "../sandbox/sandbox-env"; +import { readCachedInstallationRepositories } from "../repos/cache"; +import { createSourceControlProviderFromEnv } from "../source-control"; import { prepareLegacyManagedProviderEnv } from "../sandbox/managed-provider-env"; import type { Env } from "../types"; import type { SqlDatabase } from "../db/sql-database"; @@ -10,6 +10,7 @@ import { hashImageBuildCallbackToken, IMAGE_BUILD_CALLBACK_TOKEN_TTL_MS, } from "./callback-auth"; +import { resolveImageBuildTokenScope } from "./credential-scope"; import type { ImageBuildScope } from "./model"; import { loadScopeBuildSecrets, @@ -53,7 +54,7 @@ export interface ImageBuildPlannerPort { * Resolves a trigger request into a concrete provider build plan. * * The planner is the only image-build layer that loads secrets, and it leans - * on scope.ts for everything kind-specific. Split deliberately: resolveTarget + * on scope.ts for targets, settings and secrets. Split deliberately: resolveTarget * and createCallbackAuth run BEFORE the build row is registered (cheap D1 * read + pure crypto), while planBuild — which decrypts secrets — runs AFTER, * so a concurrent secret change always sees a row to supersede and the @@ -88,7 +89,7 @@ export class ImageBuildPlanner implements ImageBuildPlannerPort { const [sandboxSettings, userEnvVars, cloneAuth] = await Promise.all([ resolveScopeSandboxSettings(this.db, params.scope, primary), loadScopeBuildSecrets(this.env, this.db, params.scope, params.target), - this.resolveCloneAuth(params.scope), + this.resolveCloneAuth(params.scope, params.target), ]); const basePlan = { @@ -119,16 +120,17 @@ export class ImageBuildPlanner implements ImageBuildPlannerPort { }; } - private async resolveCloneAuth(scope: ImageBuildScope): Promise { + private async resolveCloneAuth( + scope: ImageBuildScope, + target: ResolvedImageBuildTarget + ): Promise { try { + const tokenScope = await resolveImageBuildTokenScope(this.db, scope, target, () => + readCachedInstallationRepositories(this.env) + ); const provider = createSourceControlProviderFromEnv(this.env); - const auth = await provider.generateCredentialHelperAuth(); - return { - type: "credential_helper", - host: scmCloneIdentity(resolveScmProviderFromEnv(this.env.SCM_PROVIDER)).host, - username: auth.username, - token: auth.password, - }; + const auth = await provider.generateCredentialHelperAuth(tokenScope); + return { type: "credential_helper", token: auth.password }; } catch (e) { logger.warn("image_build.clone_token_failed", { error: e instanceof Error ? e.message : String(e), diff --git a/packages/control-plane/src/image-builds/scheduler.test.ts b/packages/control-plane/src/image-builds/scheduler.test.ts index bb8029810a..14a557306f 100644 --- a/packages/control-plane/src/image-builds/scheduler.test.ts +++ b/packages/control-plane/src/image-builds/scheduler.test.ts @@ -1,7 +1,10 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EnvironmentStore, type EnvironmentRepositoryRow } from "../db/environments"; import type { ImageBuildStore } from "../db/image-builds"; import type { SqlDatabase } from "../db/sql-database"; import type { Job } from "../jobs"; +import { readCachedInstallationRepositories } from "../repos/cache"; +import type * as ReposCacheModule from "../repos/cache"; import { createTestEnv } from "../router.test-support"; import type { SourceControlProvider } from "../source-control"; import type { Env } from "../types"; @@ -12,6 +15,64 @@ import type { ResolvedImageBuildTarget } from "./scope"; import { COMPATIBLE_RUNTIME_VERSION } from "./test-helpers"; import type { ImageBuildWorkflow } from "./workflow"; +vi.mock("../repos/cache", async (importOriginal) => ({ + ...(await importOriginal()), + readCachedInstallationRepositories: vi.fn(), +})); + +const ENV_TARGET: ResolvedImageBuildTarget = { + kind: "environment", + repositories: [ + { repoOwner: "acme", repoName: "web", baseBranch: "main" }, + { repoOwner: "acme", repoName: "api", baseBranch: "develop" }, + ], + repositoriesFingerprint: "fp-env", +}; +const ENV_REPOSITORIES: EnvironmentRepositoryRow[] = ENV_TARGET.repositories.map( + (repository, position) => ({ + environment_id: "env_1", + position, + repo_owner: repository.repoOwner, + repo_name: repository.repoName, + repo_id: null, + base_branch: repository.baseBranch, + }) +); + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(EnvironmentStore.prototype, "getById").mockResolvedValue({ + id: "env_1", + name: "Environment", + description: null, + prebuild_enabled: 1, + channel_associations: null, + owner_team_id: null, + created_at: 1, + updated_at: 1, + }); + vi.spyOn(EnvironmentStore.prototype, "getRepositoriesForEnvironment").mockResolvedValue( + ENV_REPOSITORIES + ); + vi.mocked(readCachedInstallationRepositories).mockResolvedValue( + [ + { id: 1, name: "web" }, + { id: 2, name: "api" }, + { id: 99, name: "sibling" }, + ].map((repository) => ({ + ...repository, + owner: "acme", + fullName: `acme/${repository.name}`, + description: null, + private: true, + defaultBranch: "main", + archived: false, + })) + ); +}); + +afterEach(() => vi.restoreAllMocks()); + function harness( options: { provider?: "modal" | "daytona" | null; @@ -141,6 +202,31 @@ function harness( }; } +function mockReadyImages({ store, resolveTarget }: ReturnType): void { + store.getReconciliationStatus.mockImplementation(async (scope: ImageBuildScope) => { + const target = await resolveTarget({} as Env, {} as SqlDatabase, scope); + return [ + { + id: `build-${scope.id}`, + scopeKind: scope.kind, + scopeId: scope.id, + provider: "modal", + status: "ready", + repositoriesFingerprint: target.repositoriesFingerprint, + repositoryShas: target.repositories.map((repository) => ({ + repoOwner: repository.repoOwner, + repoName: repository.repoName, + baseSha: "abc123", + })), + runtimeVersion: COMPATIBLE_RUNTIME_VERSION, + buildDurationSeconds: 1, + errorMessage: null, + createdAt: 1, + }, + ]; + }); +} + describe("ImageBuildScheduler", () => { it("contains cleanup failures and still dispatches rebuilds", async () => { const { scheduler, store, adapter, workflow, resolveTarget } = harness(); @@ -218,9 +304,10 @@ describe("ImageBuildScheduler", () => { it("checks every enabled scope in one full scan", async () => { const getBranchHead = vi.fn(async () => "abc123"); - const { scheduler, store, resolveTarget, listScopes } = harness({ + const h = harness({ sourceControl: { getBranchHead } as unknown as SourceControlProvider, }); + const { scheduler, resolveTarget, listScopes } = h; listScopes.mockResolvedValue( Array.from({ length: 41 }, (_, index) => ({ kind: "repo" as const, @@ -243,28 +330,7 @@ describe("ImageBuildScheduler", () => { }; } ); - store.getReconciliationStatus.mockImplementation(async (scope: ImageBuildScope) => { - const target = await resolveTarget({} as Env, {} as SqlDatabase, scope); - return [ - { - id: `build-${scope.id}`, - scopeKind: scope.kind, - scopeId: scope.id, - provider: "modal", - status: "ready", - repositoriesFingerprint: target.repositoriesFingerprint, - repositoryShas: target.repositories.map((repository) => ({ - repoOwner: repository.repoOwner, - repoName: repository.repoName, - baseSha: "abc123", - })), - runtimeVersion: COMPATIBLE_RUNTIME_VERSION, - buildDurationSeconds: 1, - errorMessage: null, - createdAt: 1, - }, - ]; - }); + mockReadyImages(h); const stats = await scheduler.run({ request_id: "cron-1", trace_id: "cron-1" }); @@ -272,6 +338,68 @@ describe("ImageBuildScheduler", () => { expect(stats.branchLookups).toBe(41); }); + it("scopes every environment branch read to the resolved member ids", async () => { + const getBranchHead = vi.fn(async () => "abc123"); + const env = createTestEnv(); + const h = harness({ + env, + sourceControl: { getBranchHead } as unknown as SourceControlProvider, + }); + h.listScopes.mockResolvedValue([{ kind: "environment", id: "env_1" }]); + h.resolveTarget.mockResolvedValue(ENV_TARGET); + mockReadyImages(h); + + const stats = await h.scheduler.run({ request_id: "cron-1", trace_id: "cron-1" }); + + const expectedScope = { kind: "repositories", repositoryIds: [1, 2] }; + expect(readCachedInstallationRepositories).toHaveBeenCalledExactlyOnceWith(env); + expect(getBranchHead).toHaveBeenCalledWith( + { owner: "acme", name: "web", branch: "main" }, + expectedScope + ); + expect(getBranchHead).toHaveBeenCalledWith( + { owner: "acme", name: "api", branch: "develop" }, + expectedScope + ); + expect(stats.branchMatched).toBe(2); + }); + + it("skips branch reads and rebuilds after environment membership changes", async () => { + vi.mocked(EnvironmentStore.prototype.getRepositoriesForEnvironment).mockResolvedValue([ + ENV_REPOSITORIES[0], + { ...ENV_REPOSITORIES[1], repo_name: "sibling" }, + ]); + const getBranchHead = vi.fn(async () => "abc123"); + const h = harness({ sourceControl: { getBranchHead } as unknown as SourceControlProvider }); + h.listScopes.mockResolvedValue([{ kind: "environment", id: "env_1" }]); + h.resolveTarget.mockResolvedValue(ENV_TARGET); + mockReadyImages(h); + + const stats = await h.scheduler.run({ request_id: "cron-1", trace_id: "cron-1" }); + + expect(stats.scopesScanned).toBe(1); + expect(stats.branchLookups).toBe(0); + expect(getBranchHead).not.toHaveBeenCalled(); + expect(h.workflow.triggerBuildWithTarget).not.toHaveBeenCalled(); + }); + + it("does not broaden repository scope when the provider refuses branch auth", async () => { + const getBranchHead = vi.fn(async () => { + throw new Error("Token scope denied"); + }); + const h = harness({ sourceControl: { getBranchHead } as unknown as SourceControlProvider }); + mockReadyImages(h); + + const stats = await h.scheduler.run({ request_id: "cron-1", trace_id: "cron-1" }); + + expect(getBranchHead).toHaveBeenCalledExactlyOnceWith( + { owner: "acme", name: "web", branch: "main" }, + { kind: "repositories", repositoryIds: [1] } + ); + expect(stats.branchUnknown).toBe(1); + expect(h.workflow.triggerBuildWithTarget).not.toHaveBeenCalled(); + }); + it("starts every required build found by the full scan", async () => { const { scheduler, listScopes, workflow } = harness(); listScopes.mockResolvedValue( diff --git a/packages/control-plane/src/image-builds/scheduler.ts b/packages/control-plane/src/image-builds/scheduler.ts index 8bc6908fb6..b3e0dea610 100644 --- a/packages/control-plane/src/image-builds/scheduler.ts +++ b/packages/control-plane/src/image-builds/scheduler.ts @@ -1,9 +1,11 @@ import { ImageBuildStore } from "../db/image-builds"; import { createLogger, type CorrelationContext } from "../logger"; +import { readCachedInstallationRepositories } from "../repos/cache"; import { createSourceControlProviderFromEnv, type SourceControlProvider } from "../source-control"; import { errorMessage } from "./errors"; import { imageBuildFinalizationJob } from "./finalization-job"; import type { ImageBuildProvider } from "./model"; +import { resolveImageBuildTokenScope } from "./credential-scope"; import { createImageBuildAdapterFactory, type ImageBuildAdapterFactory } from "./provider-factory"; import { DEFAULT_ARTIFACT_CLEANUP_MAX_AGE_MS, DEFAULT_STALE_BUILD_MAX_AGE_MS } from "./maintenance"; import { evaluateImageBuildRebuildPolicy } from "./rebuild-policy"; @@ -254,15 +256,21 @@ export class ImageBuildScheduler { let rebuild = decision.type === "rebuild"; if (decision.type === "check_branches") { + const tokenScope = await resolveImageBuildTokenScope(this.db, scope, target, () => + readCachedInstallationRepositories(this.env) + ); const heads: Array = []; for (const repository of target.repositories) { stats.branchLookups += 1; try { - const head = await sourceControl.getBranchHead({ - owner: repository.repoOwner, - name: repository.repoName, - branch: repository.baseBranch, - }); + const head = await sourceControl.getBranchHead( + { + owner: repository.repoOwner, + name: repository.repoName, + branch: repository.baseBranch, + }, + tokenScope + ); heads.push(head); if (head === null) { stats.branchMissing += 1; diff --git a/packages/control-plane/src/image-builds/scope.test.ts b/packages/control-plane/src/image-builds/scope.test.ts index e0822afb5b..aa17d96ec4 100644 --- a/packages/control-plane/src/image-builds/scope.test.ts +++ b/packages/control-plane/src/image-builds/scope.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type * as SourceControlModule from "../source-control"; import type * as IntegrationSettingsResolutionModule from "../session/integration-settings-resolution"; import type { Env } from "../types"; +import { EnvironmentStore } from "../db/environments"; import { SecretDecryptionError } from "../db/scoped-secrets"; import { ImageBuildPlanningError, ImageBuildScopeNotFoundError } from "./errors"; import { computeRepositoriesFingerprint } from "./fingerprint"; @@ -299,6 +300,15 @@ describe("listEnabledScopes", () => { { kind: "repo", id: "acme/web" }, ]); }); + + it("omits environments the filter excludes", async () => { + const db = fakeDb({ + environment: { id: "env_1", prebuild_enabled: 1 }, + enabledRepos: [{ repo_owner: "acme", repo_name: "web" }], + }); + + expect(await listEnabledScopes(db, () => false)).toEqual([{ kind: "repo", id: "acme/web" }]); + }); }); describe("listEnabledScopeUnits", () => { @@ -340,6 +350,20 @@ describe("listEnabledScopeUnits", () => { expect(units.map((unit) => unit.scope.kind)).toEqual(["environment"]); }); + + it("drops filtered environments before hydrating their repositories", async () => { + const db = fakeDb({ + environment: { id: "env_hidden", prebuild_enabled: 1, name: "Hidden" }, + repositories: [{ position: 0, repo_owner: "acme", repo_name: "api", base_branch: "dev" }], + }); + const hydrate = vi.spyOn(EnvironmentStore.prototype, "getRepositoriesForEnvironmentIds"); + + const units = await listEnabledScopeUnits(envWith(db), db, (row) => row.id !== "env_hidden"); + + expect(units).toEqual([]); + expect(hydrate).toHaveBeenCalledWith([]); + hydrate.mockRestore(); + }); }); describe("resolveScopeSandboxSettings", () => { diff --git a/packages/control-plane/src/image-builds/scope.ts b/packages/control-plane/src/image-builds/scope.ts index 441680ec4a..3b6a72dfeb 100644 --- a/packages/control-plane/src/image-builds/scope.ts +++ b/packages/control-plane/src/image-builds/scope.ts @@ -11,7 +11,7 @@ */ import { EnvironmentSecretsStore } from "../db/environment-secrets"; -import { EnvironmentStore } from "../db/environments"; +import { EnvironmentStore, type EnvironmentRow } from "../db/environments"; import { GlobalSecretsStore } from "../db/global-secrets"; import { RepoMetadataStore } from "../db/repo-metadata"; import { RepoSecretsStore } from "../db/repo-secrets"; @@ -180,10 +180,16 @@ export async function resolveScopeEnabled( } /** Every prebuild-enabled scope, cheap form (ids only) for status aggregation. */ -export async function listEnabledScopes(db: SqlDatabase): Promise { +/** Narrows environment rows before any per-environment work; omitted means every row. */ +export type EnvironmentRowFilter = (row: EnvironmentRow) => boolean; + +export async function listEnabledScopes( + db: SqlDatabase, + includeEnvironment: EnvironmentRowFilter = () => true +): Promise { const { environments } = await new EnvironmentStore(db).list(); const environmentScopes = environments - .filter((row) => row.prebuild_enabled === 1) + .filter((row) => row.prebuild_enabled === 1 && includeEnvironment(row)) .map((row) => ({ kind: "environment" as const, id: row.id })); const repos = await new RepoMetadataStore(db).getImageBuildEnabledRepos(); @@ -200,11 +206,14 @@ export async function listEnabledScopes(db: SqlDatabase): Promise true ): Promise { const store = new EnvironmentStore(db); const { environments } = await store.list(); - const enabled = environments.filter((row) => row.prebuild_enabled === 1); + const enabled = environments.filter( + (row) => row.prebuild_enabled === 1 && includeEnvironment(row) + ); const repositoriesById = await store.getRepositoriesForEnvironmentIds( enabled.map((row) => row.id) ); diff --git a/packages/control-plane/src/image-builds/types.ts b/packages/control-plane/src/image-builds/types.ts index cc06769080..433fa8b443 100644 --- a/packages/control-plane/src/image-builds/types.ts +++ b/packages/control-plane/src/image-builds/types.ts @@ -25,7 +25,7 @@ export type TriggerImageBuildResult = /** Clone auth handed to provider-session build sandboxes (provider-policy.ts). */ export type ImageBuildCloneAuth = - | { type: "credential_helper"; host: string; username: string; token: string } + | { type: "credential_helper"; token: string } | { type: "unavailable" }; /** diff --git a/packages/control-plane/src/image-builds/vercel-adapter.test.ts b/packages/control-plane/src/image-builds/vercel-adapter.test.ts index e11cfcf903..7b2663182e 100644 --- a/packages/control-plane/src/image-builds/vercel-adapter.test.ts +++ b/packages/control-plane/src/image-builds/vercel-adapter.test.ts @@ -24,8 +24,6 @@ function createPlan(buildTimeoutMs = 1_800_001): ImageBuildPlan { callbackToken: "callback-token", cloneAuth: { type: "credential_helper", - host: "github.com", - username: "x-access-token", token: "clone-token", }, buildTimeoutMs, diff --git a/packages/control-plane/src/repos/cache.test.ts b/packages/control-plane/src/repos/cache.test.ts new file mode 100644 index 0000000000..ca7c94425b --- /dev/null +++ b/packages/control-plane/src/repos/cache.test.ts @@ -0,0 +1,165 @@ +import type { EnrichedRepository } from "@open-inspect/shared/types/repository-catalog"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SourceControlProviderError } from "../source-control/errors"; +import { + REPOS_CACHE_KEY, + readCachedInstallationRepositories, + reposCacheIdentity, + type CachedReposList, +} from "./cache"; + +type CacheEnv = Parameters[0]; + +const cacheStore = { get: vi.fn(), put: vi.fn(), delete: vi.fn() }; +const mockFetch = vi.fn(); +const repository: EnrichedRepository = { + id: 42, + owner: "acme", + name: "widgets", + fullName: "acme/widgets", + description: null, + private: true, + defaultBranch: "main", + archived: false, + language: "TypeScript", + topics: ["widgets"], + metadata: { aliases: ["widget-service"] }, +}; + +function createEnv(overrides: Partial = {}): CacheEnv { + return { + REPOS_CACHE: cacheStore, + SCM_PROVIDER: "github", + GITHUB_APP_INSTALLATION_ID: "installation-1", + ...overrides, + }; +} + +async function cachedCatalog(env: CacheEnv): Promise { + return { + repos: [repository], + cachedAt: "2026-01-01T00:00:00.000Z", + scmIdentity: await reposCacheIdentity(env), + freshUntil: 4_102_444_800_000, + }; +} + +describe("readCachedInstallationRepositories", () => { + beforeEach(() => { + vi.resetAllMocks(); + mockFetch.mockRejectedValue(new Error("Unexpected network request")); + vi.stubGlobal("fetch", mockFetch); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + expect(cacheStore.put).not.toHaveBeenCalled(); + expect(cacheStore.delete).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("returns the validated enriched catalog using only the existing cache key", async () => { + const env = createEnv(); + cacheStore.get.mockResolvedValue(await cachedCatalog(env)); + + await expect(readCachedInstallationRepositories(env)).resolves.toEqual([repository]); + + expect(cacheStore.get).toHaveBeenCalledExactlyOnceWith(REPOS_CACHE_KEY, "json"); + }); + + it.each([0, undefined])("reuses entries with freshUntil %s", async (freshUntil) => { + const env = createEnv(); + const cached = await cachedCatalog(env); + if (freshUntil === undefined) delete cached.freshUntil; + else cached.freshUntil = freshUntil; + cacheStore.get.mockResolvedValue(cached); + + await expect(readCachedInstallationRepositories(env)).resolves.toEqual([repository]); + }); + + it("retains nested GitLab owners and repository IDs", async () => { + const env = createEnv({ + SCM_PROVIDER: "gitlab", + GITLAB_NAMESPACE: "acme/platform/services", + GITLAB_ACCESS_TOKEN: "token-1", + }); + const nestedRepository = { + ...repository, + owner: "acme/platform/services", + fullName: "acme/platform/services/widgets", + }; + cacheStore.get.mockResolvedValue({ + ...(await cachedCatalog(env)), + repos: [nestedRepository], + }); + + await expect(readCachedInstallationRepositories(env)).resolves.toEqual([nestedRepository]); + }); + + it.each([ + { + name: "a different GitHub installation", + cachedConfig: {}, + requestedConfig: { GITHUB_APP_INSTALLATION_ID: "installation-2" }, + }, + { + name: "a different provider", + cachedConfig: {}, + requestedConfig: { SCM_PROVIDER: "gitlab" }, + }, + { + name: "a rotated GitLab token", + cachedConfig: { + SCM_PROVIDER: "gitlab", + GITLAB_NAMESPACE: "acme/platform", + GITLAB_ACCESS_TOKEN: "token-1", + }, + requestedConfig: { GITLAB_ACCESS_TOKEN: "token-2" }, + }, + ])("fails closed for $name", async ({ cachedConfig, requestedConfig }) => { + cacheStore.get.mockResolvedValue(await cachedCatalog(createEnv(cachedConfig))); + + await expect( + readCachedInstallationRepositories(createEnv({ ...cachedConfig, ...requestedConfig })) + ).rejects.toMatchObject({ + errorType: "permanent", + message: "Installation repository catalog cache does not match SCM configuration", + }); + }); + + it.each([ + { name: "a missing entry", entry: () => null }, + { + name: "an invalid repository", + entry: (valid: CachedReposList) => ({ ...valid, repos: [{ ...repository, id: "42" }] }), + }, + { + name: "a missing SCM identity", + entry: (valid: CachedReposList) => ({ ...valid, scmIdentity: undefined }), + }, + ])("fails closed for $name", async ({ entry }) => { + const env = createEnv(); + cacheStore.get.mockResolvedValue(entry(await cachedCatalog(env))); + + const read = readCachedInstallationRepositories(env); + + await expect(read).rejects.toBeInstanceOf(SourceControlProviderError); + await expect(read).rejects.toMatchObject({ + errorType: "permanent", + message: "Installation repository catalog cache is missing or malformed", + }); + }); + + it.each([new SourceControlProviderError("KV timeout", "transient"), "non-Error store failure"])( + "wraps store read failures as permanent errors (%s)", + async (cause) => { + cacheStore.get.mockRejectedValue(cause); + + await expect(readCachedInstallationRepositories(createEnv())).rejects.toMatchObject({ + errorType: "permanent", + message: "Failed to read installation repository catalog cache", + cause: cause instanceof Error ? cause : undefined, + }); + } + ); +}); diff --git a/packages/control-plane/src/repos/cache.ts b/packages/control-plane/src/repos/cache.ts new file mode 100644 index 0000000000..740bd206a7 --- /dev/null +++ b/packages/control-plane/src/repos/cache.ts @@ -0,0 +1,80 @@ +import { sha256Hex } from "@open-inspect/shared/service-auth"; +import { + enrichedRepositorySchema, + type InstallationRepository, +} from "@open-inspect/shared/types/repository-catalog"; +import { z } from "zod"; +import { resolveScmProviderFromEnv } from "../source-control/config"; +import { SourceControlProviderError } from "../source-control/errors"; +import type { Env } from "../types"; + +export const REPOS_CACHE_KEY = "repos:list:v3"; + +export async function reposCacheIdentity( + env: Pick< + Env, + "SCM_PROVIDER" | "GITHUB_APP_INSTALLATION_ID" | "GITLAB_NAMESPACE" | "GITLAB_ACCESS_TOKEN" + > +): Promise { + const provider = resolveScmProviderFromEnv(env.SCM_PROVIDER); + let identity: string[] = [provider]; + if (provider === "github") identity = [provider, env.GITHUB_APP_INSTALLATION_ID ?? ""]; + if (provider === "gitlab") { + identity = [provider, env.GITLAB_NAMESPACE ?? "", env.GITLAB_ACCESS_TOKEN ?? ""]; + } + return await sha256Hex(JSON.stringify(identity)); +} + +export const cachedReposListSchema = z.object({ + repos: z.array(enrichedRepositorySchema), + cachedAt: z.string(), + scmIdentity: z.string(), + // Missing in entries cached before this field was added. + freshUntil: z.number().optional(), +}); + +export type CachedReposList = z.infer; + +/** + * Read the installation catalog without fetching, minting credentials, or writing to cache. + * Stale entries are usable while present in KV because repository IDs are stable. + */ +export async function readCachedInstallationRepositories( + env: Pick< + Env, + | "REPOS_CACHE" + | "SCM_PROVIDER" + | "GITHUB_APP_INSTALLATION_ID" + | "GITLAB_NAMESPACE" + | "GITLAB_ACCESS_TOKEN" + > +): Promise { + const scmIdentity = await reposCacheIdentity(env); + let raw: unknown; + try { + raw = await env.REPOS_CACHE.get(REPOS_CACHE_KEY, "json"); + } catch (e) { + throw new SourceControlProviderError( + "Failed to read installation repository catalog cache", + "permanent", + undefined, + e instanceof Error ? e : undefined + ); + } + + const cached = cachedReposListSchema.safeParse(raw); + if (!cached.success) { + throw new SourceControlProviderError( + "Installation repository catalog cache is missing or malformed", + "permanent" + ); + } + if (cached.data.scmIdentity !== scmIdentity) { + throw new SourceControlProviderError( + "Installation repository catalog cache does not match SCM configuration", + "permanent" + ); + } + + return cached.data.repos; +} diff --git a/packages/control-plane/src/router.create-session.test.ts b/packages/control-plane/src/router.create-session.test.ts index a71e3c472e..5de8f9e2de 100644 --- a/packages/control-plane/src/router.create-session.test.ts +++ b/packages/control-plane/src/router.create-session.test.ts @@ -27,6 +27,8 @@ const { getAccessToken } = vi.hoisted(() => ({ })), })); +const environmentMocks = vi.hoisted(() => ({ getById: vi.fn() })); + vi.mock("./auth/user/runtime", () => ({ getUserAuth: vi.fn(() => ({ api: { @@ -56,6 +58,12 @@ vi.mock("./db/user-store", () => ({ UserStore: vi.fn(), })); +vi.mock("./db/environments", () => ({ + EnvironmentStore: vi.fn().mockImplementation(function () { + return environmentMocks; + }), +})); + vi.mock("./session/skill-resolution", async (importOriginal) => { const actual = (await importOriginal()) as Record; return { @@ -90,6 +98,8 @@ describe("handleCreateSession D1 ordering", () => { afterEach(() => vi.restoreAllMocks()); beforeEach(() => { vi.clearAllMocks(); + vi.spyOn(TeamStore.prototype, "isActive").mockResolvedValue(true); + environmentMocks.getById.mockResolvedValue({ id: "env_1", owner_team_id: null }); vi.mocked(resolveManagedSkills).mockResolvedValue({ selection: { mode: "all" }, resolverVersion: 1, @@ -232,7 +242,23 @@ describe("handleCreateSession D1 ordering", () => { }; } - it("rejects a team session when its repository is not granted", async () => { + it.each([ + { + target: "scalar repository", + body: { repoOwner: "acme", repoName: "web-app" }, + repository: "acme/web-app", + }, + { + target: "repository list", + body: { repositories: [{ repoOwner: "acme", repoName: "widgets" }] }, + repository: "acme/widgets", + }, + { + target: "environment", + body: { environmentId: "env_1" }, + repository: "acme/environment-repo", + }, + ])("rejects a team session when its $target is not granted", async ({ body, repository }) => { vi.spyOn(TeamStore.prototype, "getById").mockResolvedValue({ id: "team_alpha", slug: "alpha", @@ -250,16 +276,51 @@ describe("handleCreateSession D1 ordering", () => { new Map([["team_alpha", "member"]]) ); vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([]); - const response = await createSessionRequestWithBody(createEnv(vi.fn()), { + const initFetch = vi.fn(); + const create = vi.fn(); + vi.mocked(SessionIndexStore).mockImplementation(function () { + return { create } as never; + }); + const response = await createSessionRequestWithBody(createEnv(initFetch), { teamId: "team_alpha", - repoOwner: "acme", - repoName: "web-app", + ...body, }); expect(response.status).toBe(409); expect(await response.json()).toMatchObject({ code: "target_team_missing_grant", - repository: "acme/web-app", + repository, + }); + expect(create).not.toHaveBeenCalled(); + expect(initFetch).not.toHaveBeenCalled(); + }); + + it("checks membership before disclosing team repository grants", async () => { + vi.spyOn(TeamStore.prototype, "getById").mockResolvedValue({ + id: "team_alpha", + slug: "alpha", + name: "Alpha", + description: null, + joinPolicy: "invite_only", + defaultVisibility: "team", + defaultEnvironmentId: null, + grantsVersion: 0, + archivedAt: null, + createdAt: 1, + updatedAt: 1, + }); + vi.spyOn(TeamMembershipStore.prototype, "listForUser").mockResolvedValue(new Map()); + const grants = vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam"); + const initFetch = vi.fn(); + const response = await createSessionRequestWithBody(createEnv(initFetch), { + teamId: "team_alpha", + repoOwner: "acme", + repoName: "web-app", }); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ code: "not_member" }); + expect(grants).not.toHaveBeenCalled(); + expect(initFetch).not.toHaveBeenCalled(); }); it("uses the team's default visibility when creating an owned session", async () => { @@ -300,6 +361,31 @@ describe("handleCreateSession D1 ordering", () => { ); }); + it("rejects a service actor's team environment for a private workspace destination before resolution/writes", async () => { + environmentMocks.getById.mockResolvedValue({ id: "env_1", owner_team_id: "team_a" }); + vi.spyOn(TeamMembershipStore.prototype, "listForUser").mockResolvedValue( + new Map([["team_a", "member"]]) + ); + const create = vi.fn(); + vi.mocked(SessionIndexStore).mockImplementation(function () { + return { create } as never; + }); + const initFetch = vi.fn(); + const response = await createSessionRequestWithBody(createEnv(initFetch), { + environmentId: "env_1", + teamId: null, + visibility: "private", + }); + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + code: "environment_team_mismatch", + reason_code: "environment_team_mismatch", + }); + expect(resolveEnvironmentTarget).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + expect(initFetch).not.toHaveBeenCalled(); + }); + it.each([ { target: "environment", @@ -364,6 +450,10 @@ describe("handleCreateSession D1 ordering", () => { permission: deniedPermission, }); expect(create).not.toHaveBeenCalled(); + expect(initFetch).not.toHaveBeenCalled(); + expect(resolveRepoOrError).not.toHaveBeenCalled(); + expect(resolveSessionRepositories).not.toHaveBeenCalled(); + expect(resolveEnvironmentTarget).not.toHaveBeenCalled(); } else { expect(create).toHaveBeenCalledOnce(); } diff --git a/packages/control-plane/src/router.policy.test.ts b/packages/control-plane/src/router.policy.test.ts index d90c4d93ad..c5442af0fc 100644 --- a/packages/control-plane/src/router.policy.test.ts +++ b/packages/control-plane/src/router.policy.test.ts @@ -24,11 +24,11 @@ describe("route policy table", () => { }); it("publishes the complete canonical route catalog", () => { - expect(routes).toHaveLength(216); + expect(routes).toHaveLength(220); const paths = routes.map((route) => route.path); - expect(new Set(paths).size).toBe(165); - expect(new Set(routes.map((route) => `${route.method}:${route.path}`)).size).toBe(216); + expect(new Set(paths).size).toBe(167); + expect(new Set(routes.map((route) => `${route.method}:${route.path}`)).size).toBe(220); }); it("gates run analytics with analytics.read", () => { @@ -241,6 +241,11 @@ describe("route policy table", () => { ], ["GET", "/integration-settings/slack/watched-channels", [{ service: "slack-bot" }]], ["GET", "/model-preferences", [{ service: "slack-bot" }]], + ["GET", "/automations", [{ service: "slack-bot" }]], + ["GET", "/automations/auto-1", [{ service: "slack-bot" }]], + ["GET", "/automations/auto-1/invocations", [{ service: "slack-bot" }]], + ["GET", "/automations/auto-1/runs/run-1", [{ service: "slack-bot" }]], + ["GET", "/integration-settings/slack/channels", [{ service: "slack-bot" }]], ["GET", "/sessions/session-1/events", [{ service: "slack-bot" }, { service: "linear-bot" }]], ["GET", "/sessions/session-1/artifacts", [{ service: "slack-bot" }, { service: "linear-bot" }]], ])("declares the exact actorless grants for %s %s", (method, path, expected) => { @@ -264,6 +269,11 @@ describe("route policy table", () => { routeFor("GET", "/integration-settings/github/resolved/acme/widgets"), routeFor("GET", "/integration-settings/slack/watched-channels"), routeFor("GET", "/model-preferences"), + routeFor("GET", "/automations"), + routeFor("GET", "/automations/auto-1"), + routeFor("GET", "/automations/auto-1/invocations"), + routeFor("GET", "/automations/auto-1/runs/run-1"), + routeFor("GET", "/integration-settings/slack/channels"), routeFor("GET", "/sessions/session-1/events"), routeFor("GET", "/sessions/session-1/artifacts"), routeFor("POST", "/sessions/session-1/stop"), diff --git a/packages/control-plane/src/router.spawn-child-grants.test.ts b/packages/control-plane/src/router.spawn-child-grants.test.ts new file mode 100644 index 0000000000..4c17bbf2e2 --- /dev/null +++ b/packages/control-plane/src/router.spawn-child-grants.test.ts @@ -0,0 +1,239 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + authorizationDatabase, + createTestEnv, + fakeSessionRuntimeDispatch, + handleRequest, + signedServiceRequest, + TEST_BACKGROUND_TASK_CONTEXT, + TEST_SERVICE_SECRETS, +} from "./router.test-support"; +import { createRequestMetrics } from "./db/instrumented-sql-database"; +import { SessionIndexStore } from "./db/session-index"; +import { TeamRepositoryGrantStore } from "./db/team-repository-grants"; +import { TeamStore } from "./db/teams"; +import { TeamMembershipStore } from "./db/team-memberships"; +import { handleSpawnChild } from "./routes/session-child-spawn"; +import { withSessionRuntime } from "./routes/session-route"; +import { HttpError, resolveRepoOrError } from "./routes/shared"; +import type * as SharedRoutes from "./routes/shared"; +import * as integrationSettings from "./session/integration-settings-resolution"; +import type { SpawnContext } from "./session/spawn-context"; +import type { Env } from "./types"; + +vi.mock("./db/user-store", () => ({ + UserStore: vi.fn().mockImplementation(function () { + return { getIdentity: async () => ({ userId: "canonical-user-123" }) }; + }), +})); + +vi.mock("./routes/shared", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, resolveRepoOrError: vi.fn() }; +}); + +describe("handleSpawnChild repository grants", () => { + const parentId = "parent-session-1"; + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(TeamStore.prototype, "isActive").mockResolvedValue(true); + vi.spyOn(TeamMembershipStore.prototype, "listForUser").mockResolvedValue( + new Map([["team_alpha", "member"]]) + ); + vi.spyOn(SessionIndexStore.prototype, "getSpawnDepth").mockResolvedValue(0); + vi.spyOn(SessionIndexStore.prototype, "countTotalChildren").mockResolvedValue(0); + vi.spyOn(integrationSettings, "resolveSandboxSettings").mockResolvedValue({}); + vi.mocked(resolveRepoOrError).mockResolvedValue({ + repoId: 12345, + repoOwner: "acme", + repoName: "web-app", + defaultBranch: "main", + }); + }); + afterEach(() => vi.restoreAllMocks()); + + function makeGrantFixture( + repoId = 12345, + environmentId: string | null = null, + hasRepository = true + ) { + const spawnContext: SpawnContext = { + repoOwner: hasRepository ? "acme" : null, + repoName: hasRepository ? "web-app" : null, + repoId: hasRepository ? repoId : null, + harness: "opencode", + model: "anthropic/claude-sonnet-4-6", + reasoningEffort: null, + sandboxTimeoutMs: 14_400_000, + baseBranch: "main", + promptAuthor: { + userId: "user-1", + canonicalUserId: "canonical-user-123", + scmUserId: "12345", + scmLogin: "acmedev", + scmName: "Acme Dev", + scmEmail: "dev@acme.test", + }, + }; + vi.spyOn(SessionIndexStore.prototype, "get").mockResolvedValue({ + id: parentId, + title: null, + userId: null, + repoOwner: spawnContext.repoOwner, + repoName: spawnContext.repoName, + model: spawnContext.model, + reasoningEffort: spawnContext.reasoningEffort, + baseBranch: spawnContext.baseBranch, + status: "active", + ownerTeamId: "team_alpha", + visibility: "workspace", + environmentId, + createdAt: 1, + updatedAt: 1, + }); + const store = { + acquireChildAdmissionLease: vi + .spyOn(SessionIndexStore.prototype, "acquireChildAdmissionLease") + .mockResolvedValue(null), + create: vi.spyOn(SessionIndexStore.prototype, "create").mockResolvedValue(undefined), + }; + const childStub = { + fetch: vi.fn<(request: Request) => Promise>(), + }; + const env = createTestEnv({ + ...TEST_SERVICE_SECRETS, + SCM_PROVIDER: "github", + DB: authorizationDatabase({ + userId: "canonical-user-123", + permissions: [ + "sessions.read", + "sessions.create", + "repositories.use", + "environments.use", + "sessions.collaborate", + ], + }), + SESSION: fakeSessionRuntimeDispatch(async (request, sessionId) => + sessionId === parentId ? Response.json(spawnContext) : childStub.fetch(request) + ), + }); + return { store, env, childStub }; + } + + async function makeRequest(env: Env): Promise { + return handleRequest( + await signedServiceRequest(`https://test.local/sessions/${parentId}/children`, { + method: "POST", + body: JSON.stringify({ title: "Child task", prompt: "Do the thing" }), + service: "linear-bot", + actor: "linear:U1", + }), + env, + TEST_BACKGROUND_TASK_CONTEXT + ); + } + + it("denies a child whose inherited repository is not granted to its parent team", async () => { + const { store, env, childStub } = makeGrantFixture(); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([]); + + const response = await makeRequest(env); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + code: "target_team_missing_grant", + repository: "acme/web-app", + }); + expect(store.acquireChildAdmissionLease).not.toHaveBeenCalled(); + expect(store.create).not.toHaveBeenCalled(); + expect(childStub.fetch).not.toHaveBeenCalled(); + }); + + it.each(["service", "sandbox"] as const)( + "rejects a repo-less %s child after the parent team is archived", + async (principal) => { + const { store, env, childStub } = makeGrantFixture(12345, null, false); + vi.mocked(TeamStore.prototype.isActive).mockResolvedValue(false); + const grants = vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam"); + const response = + principal === "service" + ? await makeRequest(env) + : await handleSpawnChild( + new Request(`https://test.local/sessions/${parentId}/children`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: "Child", prompt: "Do the thing" }), + }), + env, + { id: parentId }, + withSessionRuntime(env, { + request_id: "request-1", + trace_id: "trace-1", + metrics: createRequestMetrics(), + executionCtx: TEST_BACKGROUND_TASK_CONTEXT, + db: env.DB, + principal: { kind: "sandbox", sessionId: parentId }, + }) + ); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ code: "team_not_active" }); + expect(resolveRepoOrError).not.toHaveBeenCalled(); + expect(grants).not.toHaveBeenCalled(); + expect(store.acquireChildAdmissionLease).not.toHaveBeenCalled(); + expect(store.create).not.toHaveBeenCalled(); + expect(childStub.fetch).not.toHaveBeenCalled(); + } + ); + + it("checks sandbox child grants with the current SCM ID instead of a stale inherited ID", async () => { + const { store, env: fixture, childStub } = makeGrantFixture(999, "env_parent"); + const covers = vi + .spyOn(TeamRepositoryGrantStore.prototype, "covers") + .mockImplementation(async (_teamId, ids) => ids.every((id) => id === 999)); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([]); + const env = createTestEnv({ SESSION: fixture.SESSION }); + const ctx = withSessionRuntime(env, { + request_id: "request-1", + trace_id: "trace-1", + metrics: createRequestMetrics(), + executionCtx: TEST_BACKGROUND_TASK_CONTEXT, + db: env.DB, + principal: { kind: "sandbox", sessionId: parentId }, + }); + + const response = await handleSpawnChild( + new Request(`https://test.local/sessions/${parentId}/children`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: "Child", prompt: "Do the thing" }), + }), + env, + { id: parentId }, + ctx + ); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ code: "target_team_missing_grant" }); + expect(resolveRepoOrError).toHaveBeenCalledWith(env, "acme", "web-app", ctx, expect.anything()); + expect(covers).toHaveBeenCalledWith("team_alpha", [12345]); + expect(store.acquireChildAdmissionLease).not.toHaveBeenCalled(); + expect(store.create).not.toHaveBeenCalled(); + expect(childStub.fetch).not.toHaveBeenCalled(); + }); + + it("does not fall back to an inherited ID when SCM resolution fails for a team child", async () => { + const { store, env, childStub } = makeGrantFixture(); + vi.mocked(resolveRepoOrError).mockRejectedValue(new HttpError("Repository not installed", 404)); + const grants = vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam"); + + const response = await makeRequest(env); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toMatchObject({ error: "Repository not installed" }); + expect(grants).not.toHaveBeenCalled(); + expect(store.acquireChildAdmissionLease).not.toHaveBeenCalled(); + expect(store.create).not.toHaveBeenCalled(); + expect(childStub.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/control-plane/src/router.spawn-child.test.ts b/packages/control-plane/src/router.spawn-child.test.ts index 9d3a5a0fd1..2cb5dce25f 100644 --- a/packages/control-plane/src/router.spawn-child.test.ts +++ b/packages/control-plane/src/router.spawn-child.test.ts @@ -1,5 +1,6 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { HarnessId } from "@open-inspect/shared/harnesses"; +import type { SessionVisibility } from "@open-inspect/shared/types/teams"; import { fakeSessionRuntimeDispatch, handleRequest, @@ -9,8 +10,15 @@ import { } from "./router.test-support"; import { getEffectiveEnabledModels } from "./db/model-preferences"; import { SessionIndexStore } from "./db/session-index"; +import { TeamMembershipStore } from "./db/team-memberships"; +import { TeamRepositoryGrantStore } from "./db/team-repository-grants"; +import { TeamStore } from "./db/teams"; +import { resolveRepoOrError } from "./routes/shared"; +import type * as SharedRoutes from "./routes/shared"; import { SessionInternalPaths } from "./session/contracts"; +const environmentMocks = vi.hoisted(() => ({ getById: vi.fn() })); + const integrationSettingsMocks = vi.hoisted(() => ({ resolveCodeServerEnabled: vi.fn().mockResolvedValue(false), resolveVncEnabled: vi.fn().mockResolvedValue(false), @@ -21,6 +29,12 @@ vi.mock("./db/session-index", () => ({ SessionIndexStore: vi.fn(), })); +vi.mock("./db/environments", () => ({ + EnvironmentStore: vi.fn().mockImplementation(function () { + return environmentMocks; + }), +})); + vi.mock("./db/model-preferences", () => ({ getEffectiveEnabledModels: vi.fn(), })); @@ -33,6 +47,11 @@ vi.mock("./db/user-store", () => ({ vi.mock("./session/integration-settings-resolution", () => integrationSettingsMocks); +vi.mock("./routes/shared", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, resolveRepoOrError: vi.fn() }; +}); + describe("handleSpawnChild prompt enqueue handling", () => { const parentId = "parent-session-1"; @@ -97,13 +116,15 @@ describe("handleSpawnChild prompt enqueue handling", () => { const makeStore = ( parentUserId: string | null = null, context: typeof spawnContext = spawnContext, - environmentId: string | null = "env_parent" + environmentId: string | null = "env_parent", + ownerTeamId: string | null = null, + visibility: SessionVisibility = "workspace" ) => ({ get: vi.fn().mockResolvedValue({ id: parentId, userId: parentUserId, - ownerTeamId: null, - visibility: "workspace", + ownerTeamId, + visibility, repoOwner: context.repoOwner, repoName: context.repoName, environmentId, @@ -123,11 +144,16 @@ describe("handleSpawnChild prompt enqueue handling", () => { beforeEach(() => { vi.clearAllMocks(); + vi.spyOn(TeamMembershipStore.prototype, "listForUser").mockResolvedValue( + new Map([["team_alpha", "member"]]) + ); + environmentMocks.getById.mockResolvedValue({ id: "env_parent", owner_team_id: null }); vi.mocked(getEffectiveEnabledModels).mockResolvedValue(["anthropic/claude-sonnet-4-6"]); integrationSettingsMocks.resolveCodeServerEnabled.mockResolvedValue(false); integrationSettingsMocks.resolveVncEnabled.mockResolvedValue(false); integrationSettingsMocks.resolveSandboxSettings.mockResolvedValue({}); }); + afterEach(() => vi.restoreAllMocks()); it("copies the exact parent provider auth snapshot with immediate inheritance", async () => { const store = makeStore(); @@ -247,11 +273,15 @@ describe("handleSpawnChild prompt enqueue handling", () => { } it("rejects a repository-backed child when the actor cannot use repositories", async () => { - const store = makeStore(null, spawnContext, null); + const store = makeStore(null, spawnContext, null, "team_alpha"); vi.mocked(SessionIndexStore).mockImplementation(function () { return store as never; }); - const { env } = makeSuccessfulEnv(spawnContext, ["sessions.create", "sessions.collaborate"]); + const { env } = makeSuccessfulEnv(spawnContext, [ + "sessions.read", + "sessions.create", + "sessions.collaborate", + ]); const response = await makeRequest(env); @@ -261,14 +291,17 @@ describe("handleSpawnChild prompt enqueue handling", () => { permission: "repositories.use", }); expect(store.create).not.toHaveBeenCalled(); + expect(resolveRepoOrError).not.toHaveBeenCalled(); + expect(store.acquireChildAdmissionLease).not.toHaveBeenCalled(); }); it("rejects an environment-backed child when the actor cannot use environments", async () => { - const store = makeStore(); + const store = makeStore(null, spawnContext, "env_parent", "team_alpha"); vi.mocked(SessionIndexStore).mockImplementation(function () { return store as never; }); const { env } = makeSuccessfulEnv(spawnContext, [ + "sessions.read", "sessions.create", "sessions.collaborate", "repositories.use", @@ -282,6 +315,66 @@ describe("handleSpawnChild prompt enqueue handling", () => { permission: "environments.use", }); expect(store.create).not.toHaveBeenCalled(); + expect(resolveRepoOrError).not.toHaveBeenCalled(); + expect(store.acquireChildAdmissionLease).not.toHaveBeenCalled(); + }); + + const actorTargetPermissions = [ + "sessions.read", + "sessions.create", + "sessions.collaborate", + "environments.use", + ]; + + it("rejects a service actor's incompatible inherited target before settings or child admission", async () => { + const store = makeStore("canonical-user-123", spawnContext, "env_parent", null, "private"); + vi.mocked(SessionIndexStore).mockImplementation(function () { + return store as never; + }); + environmentMocks.getById.mockResolvedValue({ id: "env_parent", owner_team_id: "team_a" }); + vi.spyOn(TeamMembershipStore.prototype, "listForUser").mockResolvedValue( + new Map([["team_a", "member"]]) + ); + const { env, childStub } = makeSuccessfulEnv(spawnContext, actorTargetPermissions); + const response = await makeRequest(env); + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + code: "environment_team_mismatch", + reason_code: "environment_team_mismatch", + }); + expect(integrationSettingsMocks.resolveSandboxSettings).not.toHaveBeenCalled(); + expect(store.acquireChildAdmissionLease).not.toHaveBeenCalled(); + expect(store.create).not.toHaveBeenCalled(); + expect(childStub.fetch).not.toHaveBeenCalled(); + }); + + it("inherits matching team ownership and private visibility for a service actor", async () => { + const store = makeStore("canonical-user-123", spawnContext, "env_parent", "team_a", "private"); + vi.mocked(SessionIndexStore).mockImplementation(function () { + return store as never; + }); + environmentMocks.getById.mockResolvedValue({ id: "env_parent", owner_team_id: "team_a" }); + vi.spyOn(TeamMembershipStore.prototype, "listForUser").mockResolvedValue( + new Map([["team_a", "member"]]) + ); + vi.mocked(resolveRepoOrError).mockResolvedValue({ + repoId: 12345, + repoOwner: "acme", + repoName: "web-app", + defaultBranch: "main", + }); + vi.spyOn(TeamStore.prototype, "isActive").mockResolvedValue(true); + vi.spyOn(TeamRepositoryGrantStore.prototype, "covers").mockResolvedValue(true); + const { env, childStub } = makeSuccessfulEnv(spawnContext, actorTargetPermissions); + expect((await makeRequest(env)).status).toBe(201); + expect(store.create).toHaveBeenCalledWith( + expect.objectContaining({ + ownerTeamId: "team_a", + visibility: "private", + environmentId: "env_parent", + }) + ); + await expect(getInitBody(childStub)).resolves.toMatchObject({ environmentId: "env_parent" }); }); async function getInitBody(childStub: DurableObjectStub) { diff --git a/packages/control-plane/src/routes/automation-create.test.ts b/packages/control-plane/src/routes/automation-create.test.ts index b328b99228..8642b50661 100644 --- a/packages/control-plane/src/routes/automation-create.test.ts +++ b/packages/control-plane/src/routes/automation-create.test.ts @@ -76,6 +76,12 @@ vi.mock("../db/environments", () => ({ }), })); +vi.mock("../db/team-settings", () => ({ + TeamSettingsStore: vi.fn().mockImplementation(function () { + return { get: vi.fn(async () => ({ requireTeamOnCreate: false })) }; + }), +})); + vi.mock("../auth/model-provider-account-default-adapters", () => ({ modelProviderAccountAdapterRegistry: { get: (...args: unknown[]) => mockProviderAdapterGet(...args), @@ -144,6 +150,23 @@ describe("automation create route", () => { instructions: "Run tests", }; + it("denies repository targets before SCM lookup or persistence", async () => { + const res = await callRoute("POST", "/automations", { + body: validBody, + permissions: PERMISSION_IDS.filter((permission) => permission !== "repositories.use"), + }); + + expect(res.status).toBe(403); + await expect(res.json()).resolves.toEqual({ + error: "Forbidden", + code: "permission_required", + permission: "repositories.use", + }); + expect(resolveRepoOrError).not.toHaveBeenCalled(); + expect(mockStore.bindAutomationInsert).not.toHaveBeenCalled(); + expect(mockBatch).not.toHaveBeenCalled(); + }); + it("rejects a Slack channel condition with the wrong operator", async () => { const res = await callRoute("POST", "/automations", { body: { diff --git a/packages/control-plane/src/routes/automation-crud.ts b/packages/control-plane/src/routes/automation-crud.ts index 8631e1fef3..d35490c215 100644 --- a/packages/control-plane/src/routes/automation-crud.ts +++ b/packages/control-plane/src/routes/automation-crud.ts @@ -21,7 +21,6 @@ import { updateAutomationRequestSchema, } from "@open-inspect/shared/types/automations"; import type { ModelProviderSelections } from "@open-inspect/shared/types/provider-accounts"; -import type { PermissionId } from "@open-inspect/shared/rbac"; import { checkHarnessCompatibility, getValidHarnessOrDefault, @@ -35,6 +34,8 @@ import { type AutomationRepositoryInsert, } from "../db/automation-store"; import { SlackChannelStore } from "../db/slack-channel-store"; +import { resolveCreationOwnerTeam } from "./team-ownership"; +import { resourceViewer } from "../authorization/resource-viewer"; import { AutomationModelProviderAuthStore, toProviderSelections, @@ -44,13 +45,11 @@ import { parseAndValidateAutomationProviderSelections, } from "../model-provider-accounts/automation-provider-selection"; import { generateId } from "../auth/crypto"; -import { isSelfActingPrincipal } from "../auth/principal"; import { applyIdentityEnforcement, requireAdmittedCanonicalUserId, } from "../routing/identity-enforcement"; import { generateWebhookApiKey, hashApiKey, encryptSentrySecret } from "../auth/webhook-key"; -import { hydrateAutomation } from "../automation/hydrate"; import { Hono } from "hono"; import { admit, dispatch } from "../routing/admit"; import type { ControlPlaneHonoEnv } from "../routing/hono-env"; @@ -66,7 +65,12 @@ import type { Env } from "../types"; import type { SqlDatabase, SqlStatement } from "../db/sql-database"; import { ProviderAccountSelectionPolicyError } from "../model-provider-accounts/selection-policy"; import { createLogger } from "../logger"; -import { AUTOMATIONS_READ, AUTOMATION_MANAGE, admittedAutomation } from "./automation-shared"; +import { + AUTOMATION_READ, + AUTOMATION_MANAGE, + admittedAutomation, + hydrateAutomationResponse, +} from "./automation-shared"; import { type CreateAutomationBody, FAR_FUTURE_THRESHOLD_MS, @@ -77,13 +81,16 @@ import { getEnvironmentSelection, getRepositorySelection, getTriggerEventTypeError, - requireTargetPermissions, resolveEnvironmentSelection, resolveReasoningEffort, resolveRepositorySelection, validateSlackTriggerConfig, validateTargetCounts, + validateTeamExecutor, } from "./automation-validation"; +import { isAutomationExecutionAuthorized } from "../automation/authorization-guard"; +import { authorizeSessionTarget } from "./session-target-authorization"; +import { authorizeTeamRepositories } from "./workspace-repository-authorization"; const logger = createLogger("router:automations"); @@ -130,6 +137,19 @@ async function handleCreateAutomation( ); } + // The scheduler replays only the canonical subject admitted before RBAC. + const resolution = requireAdmittedCanonicalUserId(ctx, enforced); + if (resolution instanceof Response) return resolution; + const resolvedUserId = resolution; + const ownerTeam = await resolveCreationOwnerTeam(ctx, body.teamId ?? null); + if (ownerTeam instanceof Response) return ownerTeam; + const ownerTeamId = ownerTeam?.id ?? null; + if (ownerTeamId !== null) { + const executorError = await validateTeamExecutor(ctx.db, ownerTeamId, resolvedUserId); + if (executorError) return executorError; + } + const viewer = await resourceViewer(ctx); + const selection = getRepositorySelection(body); const requestedRepositories = selection.kind === "replace" ? selection.repositories : []; @@ -153,23 +173,32 @@ async function handleCreateAutomation( environmentSelection.kind === "replace" ? environmentSelection.environmentIds : []; validateTargetCounts(triggerType, requestedRepositories.length, requestedEnvironmentIds.length); } catch (e) { - if (e instanceof TargetSelectionError) return error(e.message, 400); + if (e instanceof TargetSelectionError) return e.response(); throw e; } - // An access token is its owner, so it faces the target checks that owner - // would: skipping them would let the credential aim an automation at - // repositories and environments the user may not use. - if (isSelfActingPrincipal(ctx.principal)) { - const targetAuthorizationError = requireTargetPermissions(ctx, [ - ...(requestedRepositories.length > 0 ? (["repositories.use"] as const) : []), - ...(requestedEnvironmentIds.length > 0 ? (["environments.use"] as const) : []), - ]); - if (targetAuthorizationError) return targetAuthorizationError; - } + const repositoryAuthorizationError = await authorizeSessionTarget(ctx, { + teamId: null, + repositories: requestedRepositories.map((repository) => ({ + owner: repository.repoOwner, + name: repository.repoName, + })), + }); + if (repositoryAuthorizationError) return repositoryAuthorizationError; + const environmentAuthorizationError = await authorizeSessionTarget(ctx, { + teamId: null, + environmentId: requestedEnvironmentIds[0], + }); + if (environmentAuthorizationError) return environmentAuthorizationError; + let environmentRepositories; try { - await resolveEnvironmentSelection(ctx.db, requestedEnvironmentIds); + environmentRepositories = await resolveEnvironmentSelection( + ctx.db, + requestedEnvironmentIds, + ownerTeamId, + viewer + ); } catch (e) { - if (e instanceof TargetSelectionError) return error(e.message, 400); + if (e instanceof TargetSelectionError) return e.response(); throw e; } @@ -223,7 +252,18 @@ async function handleCreateAutomation( return error("Invalid reasoning effort for selected model", 400); } - const newRepositories = await resolveRepositorySelection(env, requestedRepositories, ctx); + const newRepositories = await resolveRepositorySelection( + env, + requestedRepositories, + ctx, + ownerTeamId + ); + if (newRepositories instanceof Response) return newRepositories; + const environmentGrantError = await authorizeTeamRepositories(ctx, { + teamId: ownerTeamId, + repositories: environmentRepositories, + }); + if (environmentGrantError) return environmentGrantError; let providerSelections: ModelProviderSelections; try { @@ -270,17 +310,11 @@ async function handleCreateAutomation( triggerAuthData = await encryptSentrySecret(sentrySecret, env.REPO_SECRETS_ENCRYPTION_KEY); } - // The scheduler replays user_id as session identity at fire time, so the - // handler may consume only the canonical subject admitted before RBAC. - const resolution = requireAdmittedCanonicalUserId(ctx, enforced); - if (resolution instanceof Response) return resolution; - const resolvedUserId = resolution; - const db: SqlDatabase = ctx.db; const store = new AutomationStore(db); const providerAuthStore = new AutomationModelProviderAuthStore(db); const row: AutomationRow = { - owner_team_id: null, + owner_team_id: ownerTeamId, id, name: body.name.trim(), instructions: body.instructions, @@ -324,7 +358,7 @@ async function handleCreateAutomation( } await ctx.db.batch(createStatements); - const automation = await hydrateAutomation(db, (await store.getById(id))!); + const automation = await hydrateAutomationResponse(ctx, (await store.getById(id))!, viewer); logger.info("automation.created", { event: "automation.created", @@ -367,13 +401,8 @@ async function handleGetAutomation( params: { id: string }, ctx: RequestContext ): Promise { - const id = params.id; - - const store = new AutomationStore(ctx.db); - const row = await store.getById(id); - if (!row) return error("Automation not found", 404); - - return json({ automation: await hydrateAutomation(ctx.db, row) }); + const { automation, viewer } = admittedAutomation(ctx); + return json({ automation: await hydrateAutomationResponse(ctx, automation, viewer) }); } async function handleUpdateAutomation( @@ -524,18 +553,23 @@ async function handleUpdateAutomation( // it simply applies from the next invocation. const selection = getRepositorySelection(body); const environmentSelection = getEnvironmentSelection(body); - const requiredTargetPermissions: PermissionId[] = [ - ...(selection.kind === "replace" && selection.repositories.length > 0 - ? (["repositories.use"] as const) - : []), - ...(environmentSelection.kind === "replace" && environmentSelection.environmentIds.length > 0 - ? (["environments.use"] as const) - : []), - ]; - if (requiredTargetPermissions.length > 0) { - const targetAuthorizationError = requireTargetPermissions(ctx, requiredTargetPermissions); - if (targetAuthorizationError) return targetAuthorizationError; - } + const repositoryAuthorizationError = await authorizeSessionTarget(ctx, { + teamId: null, + repositories: + selection.kind === "replace" + ? selection.repositories.map((repository) => ({ + owner: repository.repoOwner, + name: repository.repoName, + })) + : undefined, + }); + if (repositoryAuthorizationError) return repositoryAuthorizationError; + const environmentAuthorizationError = await authorizeSessionTarget(ctx, { + teamId: null, + environmentId: + environmentSelection.kind === "replace" ? environmentSelection.environmentIds[0] : undefined, + }); + if (environmentAuthorizationError) return environmentAuthorizationError; // The count rules span both selections, so when EITHER is replaced they are // validated against the automation's FINAL state (the replacement plus the @@ -546,25 +580,69 @@ async function handleUpdateAutomation( const replacementEnvironmentIds: string[] | null = environmentSelection.kind === "replace" ? environmentSelection.environmentIds : null; if (selection.kind === "replace" || replacementEnvironmentIds !== null) { + const finalRepositories = + selection.kind === "replace" ? [] : await store.getRepositoriesForAutomation(id); + const finalEnvironmentIds = + replacementEnvironmentIds ?? + (await store.getEnvironmentsForAutomation(id)).map( + (environment) => environment.environment_id + ); + let environmentRepositories; try { const finalRepositoryCount = - selection.kind === "replace" - ? selection.repositories.length - : (await store.getRepositoriesForAutomation(id)).length; - const finalEnvironmentCount = + selection.kind === "replace" ? selection.repositories.length : finalRepositories.length; + validateTargetCounts(existingTriggerType, finalRepositoryCount, finalEnvironmentIds.length); + environmentRepositories = await resolveEnvironmentSelection( + ctx.db, + finalEnvironmentIds, + existing.owner_team_id, + admission.viewer, replacementEnvironmentIds !== null - ? replacementEnvironmentIds.length - : (await store.getEnvironmentsForAutomation(id)).length; - validateTargetCounts(existingTriggerType, finalRepositoryCount, finalEnvironmentCount); - if (replacementEnvironmentIds !== null) { - await resolveEnvironmentSelection(ctx.db, replacementEnvironmentIds); - } + ); } catch (e) { - if (e instanceof TargetSelectionError) return error(e.message, 400); + if (e instanceof TargetSelectionError) return e.response(); throw e; } if (selection.kind === "replace") { - replacementRepositories = await resolveRepositorySelection(env, selection.repositories, ctx); + const resolved = await resolveRepositorySelection( + env, + selection.repositories, + ctx, + existing.owner_team_id + ); + if (resolved instanceof Response) return resolved; + replacementRepositories = resolved; + } + // Stored targets are revalidated too: grants may have been revoked since they were saved. + const grantError = await authorizeTeamRepositories(ctx, { + teamId: existing.owner_team_id, + repositories: [ + ...finalRepositories.map((repository) => ({ + owner: repository.repo_owner, + name: repository.repo_name, + repoId: repository.repo_id, + })), + ...environmentRepositories, + ], + }); + if (grantError) return grantError; + // Target edits must leave the automation runnable by its current executor. + if ( + !(await isAutomationExecutionAuthorized(ctx.db, { + automationId: id, + ...(existing.user_id ? { executionUserId: existing.user_id } : {}), + requiresRepositoryUse: (replacementRepositories ?? finalRepositories).length > 0, + requiresEnvironmentUse: finalEnvironmentIds.length > 0, + })) + ) { + return json( + { + error: "The automation's executor cannot launch these targets", + code: "automation_executor_unauthorized", + reason_code: "execution_authorization_denied", + }, + 409 + ); } } @@ -693,7 +771,7 @@ async function handleUpdateAutomation( trace_id: ctx.trace_id, }); - return json({ automation: await hydrateAutomation(db, updated) }); + return json({ automation: await hydrateAutomationResponse(ctx, updated, admission.viewer) }); } async function handleDeleteAutomation( @@ -736,7 +814,7 @@ automationCrudRoutes.post( }), (c) => dispatch(c, handleCreateAutomation) ); -automationCrudRoutes.get("/automations/:id", AUTOMATIONS_READ, (c) => +automationCrudRoutes.get("/automations/:id", AUTOMATION_READ, (c) => dispatch(c, handleGetAutomation) ); automationCrudRoutes.put("/automations/:id", AUTOMATION_MANAGE, (c) => diff --git a/packages/control-plane/src/routes/automation-environment-selection.test.ts b/packages/control-plane/src/routes/automation-environment-selection.test.ts new file mode 100644 index 0000000000..d7930c0e45 --- /dev/null +++ b/packages/control-plane/src/routes/automation-environment-selection.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { SessionViewer } from "@open-inspect/shared"; +import type { SqlDatabase } from "../db/sql-database"; +import { resolveEnvironmentSelection, TargetSelectionError } from "./automation-validation"; + +const environments = vi.hoisted(() => ({ + getById: vi.fn(), + getRepositoriesForEnvironment: vi.fn(), +})); +vi.mock("../db/environments", () => ({ + EnvironmentStore: vi.fn().mockImplementation(function () { + return environments; + }), +})); +const db: SqlDatabase = { + prepare: () => { + throw new Error("Unexpected SQL query"); + }, + batch: async () => [], +}; +const viewer: SessionViewer = { + kind: "user", + userId: "executor", + roleKey: "member", + permissions: ["environments.use"], + suspended: false, + memberships: new Map([["team-a", "member"]]), +}; + +async function selectionFailure(selection: Promise) { + const result = await selection.catch((error: unknown) => error); + expect(result).toBeInstanceOf(TargetSelectionError); + const response = (result as TargetSelectionError).response(); + return { status: response.status, ...(await response.json>()) }; +} + +describe("automation environment selection", () => { + beforeEach(() => { + vi.clearAllMocks(); + environments.getById.mockResolvedValue({ id: "env_a", owner_team_id: "team-a" }); + environments.getRepositoriesForEnvironment.mockResolvedValue([ + { repo_owner: "group/subgroup", repo_name: "api", repo_id: 11 }, + ]); + }); + + it.each([true, false])("resolves use-only/unchanged targets (%s)", async (requireUse) => { + const actor = { ...viewer, permissions: requireUse ? viewer.permissions : [] }; + await expect( + resolveEnvironmentSelection(db, ["env_a"], "team-a", actor, requireUse) + ).resolves.toEqual([{ owner: "group/subgroup", name: "api", repoId: 11 }]); + }); + + it("checks replacement-use access before owner compatibility", async () => { + await expect( + resolveEnvironmentSelection(db, ["env_a"], "team-b", { ...viewer, permissions: [] }) + ).rejects.toMatchObject({ status: 403, reasonCode: "missing_permission" }); + expect(environments.getRepositoriesForEnvironment).not.toHaveBeenCalled(); + }); + + it.each([true, false])( + "aggregates hidden/missing IDs in input order before conflicts with requireUse=%s", + async (requireUse) => { + const ids = ["env_visible_cross", "env_hidden_z", "env_missing", "env_hidden_a"]; + const actor = { ...viewer, permissions: requireUse ? viewer.permissions : [] }; + environments.getById.mockImplementation(async (id: string) => + id === "env_visible_cross" ? { id, owner_team_id: "team-a" } : null + ); + const missing = await selectionFailure( + resolveEnvironmentSelection(db, ids, null, actor, requireUse) + ); + expect(missing).toEqual({ + status: 400, + error: "Environment not found: env_hidden_z, env_missing, env_hidden_a", + }); + environments.getById.mockImplementation(async (id: string) => + id === "env_missing" + ? null + : { id, owner_team_id: id === "env_visible_cross" ? "team-a" : "team-b" } + ); + expect( + await selectionFailure(resolveEnvironmentSelection(db, ids, null, actor, requireUse)) + ).toEqual(missing); + expect(environments.getRepositoriesForEnvironment).not.toHaveBeenCalled(); + } + ); + + it.each([ + ["team-a", null, true], + ["team-a", "team-b", true], + [null, "team-a", true], + ["team-a", "team-b", false], + ] as const)( + "rejects owner mismatch %s -> %s with requireUse=%s", + async (environmentTeamId, ownerTeamId, requireUse) => { + environments.getById.mockResolvedValue({ id: "env_a", owner_team_id: environmentTeamId }); + const actor = { ...viewer, permissions: requireUse ? viewer.permissions : [] }; + const result = await selectionFailure( + resolveEnvironmentSelection(db, ["env_a"], ownerTeamId, actor, requireUse) + ); + expect(result).toEqual({ + status: 409, + error: "Environment must belong to the automation's owner team", + code: "environment_team_mismatch", + reason_code: "environment_team_mismatch", + }); + expect(environments.getRepositoriesForEnvironment).not.toHaveBeenCalled(); + } + ); +}); diff --git a/packages/control-plane/src/routes/automation-executor.ts b/packages/control-plane/src/routes/automation-executor.ts new file mode 100644 index 0000000000..854dfd065b --- /dev/null +++ b/packages/control-plane/src/routes/automation-executor.ts @@ -0,0 +1,111 @@ +import { Hono } from "hono"; +import { z } from "zod"; +import { checkAutomationExecutorReassignment, isCanonicalUserId } from "@open-inspect/shared"; +import { AutomationStore, type AutomationRow } from "../db/automation-store"; +import { bindAppliedAuditEvent } from "../db/team-audit"; +import { automationActionDeniedBody } from "../authorization/owned-resource-admission"; +import { isAutomationExecutionAuthorized } from "../automation/authorization-guard"; +import { dispatch } from "../routing/admit"; +import type { ControlPlaneHonoEnv } from "../routing/hono-env"; +import type { Env } from "../types"; +import { parseBody } from "./body"; +import { error, json, type RequestContext } from "./shared"; +import { + AUTOMATION_MANAGE, + admittedAutomation, + hydrateAutomationResponse, +} from "./automation-shared"; +import { validateAutomationExecutor, validateAutomationTeam } from "./automation-validation"; + +const executorBodySchema = z.strictObject({ + userId: z.string().refine(isCanonicalUserId, "Invalid canonical user ID"), +}); + +/** + * Whether the candidate may execute this automation: an active user, a member of its active + * team, and able to launch its stored targets. Null when they may. + */ +async function validateExecutorCandidate( + ctx: RequestContext, + automation: AutomationRow, + userId: string +): Promise { + const executorError = await validateAutomationExecutor(ctx.db, userId); + if (executorError) return executorError; + const teamError = await validateAutomationTeam(ctx, automation.owner_team_id, userId); + if (teamError) return teamError; + const authorized = await isAutomationExecutionAuthorized(ctx.db, { + automationId: automation.id, + executionUserId: userId, + requiresRepositoryUse: "stored", + requiresEnvironmentUse: "stored", + }); + if (authorized) return null; + return json( + { + error: "Executor cannot launch this automation", + code: "automation_executor_unauthorized", + reason_code: "execution_authorization_denied", + }, + 403 + ); +} + +async function changeExecutor( + request: Request, + _env: Env, + params: { id: string }, + ctx: RequestContext +): Promise { + const { automation, viewer } = admittedAutomation(ctx); + const access = checkAutomationExecutorReassignment(viewer, { + ownerTeamId: automation.owner_team_id, + executorUserId: automation.user_id, + }); + if (viewer.kind !== "user" || !access.allowed) { + const reason = access.allowed ? "missing_permission" : access.reason; + return json(automationActionDeniedBody(reason, "Team lead or administrator required"), 403); + } + const body = await parseBody(request, executorBodySchema, "Invalid executor"); + if (body instanceof Response) return body; + const candidateError = await validateExecutorCandidate(ctx, automation, body.userId); + if (candidateError) return candidateError; + if (automation.user_id === body.userId) { + return json({ automation: await hydrateAutomationResponse(ctx, automation, viewer) }); + } + const store = new AutomationStore(ctx.db); + const results = await ctx.db.batch([ + store.bindExecutorChange(automation, body.userId, viewer.userId), + bindAppliedAuditEvent( + ctx.db, + { + requestId: ctx.request_id, + actorUserId: viewer.userId, + action: "automation.executor_changed", + resourceType: "automation", + resourceId: automation.id, + teamId: automation.owner_team_id, + targetUserId: body.userId, + before: { userId: automation.user_id }, + after: { userId: body.userId }, + }, + true + ), + ]); + if ((results[0]?.meta.changes ?? 0) === 0) { + // The guarded write re-checks everything above; report a candidate that stopped qualifying, + // otherwise the row or the caller's reassignment authority changed after admission. + return ( + (await validateExecutorCandidate(ctx, automation, body.userId)) ?? + json({ error: "Automation changed concurrently", code: "automation_conflict" }, 409) + ); + } + const updated = await store.getById(params.id); + if (!updated) return error("Automation not found", 404); + return json({ automation: await hydrateAutomationResponse(ctx, updated, viewer) }); +} + +export const automationExecutorRoutes = new Hono(); +automationExecutorRoutes.patch("/automations/:id", AUTOMATION_MANAGE, (c) => + dispatch(c, changeExecutor) +); diff --git a/packages/control-plane/src/routes/automation-lifecycle.test.ts b/packages/control-plane/src/routes/automation-lifecycle.test.ts index 4326e14db4..4152ad81bd 100644 --- a/packages/control-plane/src/routes/automation-lifecycle.test.ts +++ b/packages/control-plane/src/routes/automation-lifecycle.test.ts @@ -97,7 +97,7 @@ vi.mock("../scheduler/scheduler", () => ({ } }, AutomationTriggerBlockedError: class AutomationTriggerBlockedError extends Error { - constructor() { + constructor(readonly reason = "concurrent_run_active") { super("An active run already exists"); this.name = "AutomationTriggerBlockedError"; } @@ -162,6 +162,30 @@ describe("automation lifecycle routes", () => { }); describe("POST /automations/:id/trigger", () => { + it.each([ + ["own", "user-1", 201], + ["own", "another-user", 403], + ["any", "user-1", 201], + ["any", "another-user", 201], + ] as const)( + "preserves trigger.%s without read for executor %s", + async (scope, executorUserId, status) => { + mockStore.getById.mockResolvedValue({ ...sampleRow, user_id: executorUserId }); + const response = await callRoute("POST", "/automations/auto-1/trigger", { + permissions: [`automations.trigger.${scope}`], + }); + expect(response.status).toBe(status); + if (status === 201) { + expect(mockSchedulerTrigger).toHaveBeenCalledWith("auto-1", "user-1", null); + } else { + await expect(response.json()).resolves.toMatchObject({ + reason_code: "not_owner_or_lead", + }); + expect(mockSchedulerTrigger).not.toHaveBeenCalled(); + } + } + ); + it("triggers automation via the scheduler", async () => { mockStore.getById.mockResolvedValue(sampleRow); mockStore.getActiveRunForAutomation.mockResolvedValue(null); @@ -211,6 +235,21 @@ describe("automation lifecycle routes", () => { }); }); + it("returns a distinct 409 when team grants change during admission", async () => { + mockStore.getById.mockResolvedValue(sampleRow); + + mockSchedulerTrigger.mockRejectedValue( + new AutomationTriggerBlockedError("team_grants_changed") + ); + + const res = await callRoute("POST", "/automations/auto-1/trigger"); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ + error: "Team repository grants changed; retry the trigger", + code: "team_grants_changed", + }); + }); + it("returns 403 when the owner is unauthorized to execute", async () => { mockStore.getById.mockResolvedValue(sampleRow); mockSchedulerTrigger.mockRejectedValue(new AutomationExecutionUnauthorizedError()); @@ -271,10 +310,7 @@ describe("automation lifecycle routes", () => { }); expect(res.status).toBe(403); - await expect(res.json()).resolves.toMatchObject({ - code: "permission_required", - permission: "automations.trigger.own", - }); + await expect(res.json()).resolves.toMatchObject({ code: "automation_action_denied" }); expect(mockSchedulerTrigger).not.toHaveBeenCalled(); }); diff --git a/packages/control-plane/src/routes/automation-lifecycle.ts b/packages/control-plane/src/routes/automation-lifecycle.ts index da19d1f932..4142b24538 100644 --- a/packages/control-plane/src/routes/automation-lifecycle.ts +++ b/packages/control-plane/src/routes/automation-lifecycle.ts @@ -10,7 +10,6 @@ import { AutomationTriggerBlockedError, Scheduler, } from "../scheduler/scheduler"; -import { hydrateAutomation } from "../automation/hydrate"; import { Hono } from "hono"; import { admit, dispatch } from "../routing/admit"; import type { ControlPlaneHonoEnv } from "../routing/hono-env"; @@ -25,7 +24,11 @@ import type { Env } from "../types"; import { resolveGitHubCredentialAuthority } from "../source-control/github-credential-authority"; import { resolveGitHubEnrichmentForRequest } from "../session/identity"; import { createLogger } from "../logger"; -import { AUTOMATION_MANAGE, admittedAutomation } from "./automation-shared"; +import { + AUTOMATION_MANAGE, + admittedAutomation, + hydrateAutomationResponse, +} from "./automation-shared"; const logger = createLogger("router:automations"); @@ -51,7 +54,9 @@ async function handlePauseAutomation( const row = await store.getById(id); return json({ - automation: row ? await hydrateAutomation(ctx.db, row) : null, + automation: row + ? await hydrateAutomationResponse(ctx, row, admittedAutomation(ctx).viewer) + : null, }); } @@ -92,7 +97,9 @@ async function handleResumeAutomation( const row = await store.getById(id); return json({ - automation: row ? await hydrateAutomation(ctx.db, row) : null, + automation: row + ? await hydrateAutomationResponse(ctx, row, admittedAutomation(ctx).viewer) + : null, }); } @@ -130,10 +137,21 @@ async function handleTriggerAutomation( trace_id: ctx.trace_id, }); if (triggerError instanceof AutomationTriggerBlockedError) { - return error("A run is already active for this automation", 409); + return triggerError.reason === "team_grants_changed" + ? json( + { + error: "Team repository grants changed; retry the trigger", + code: "team_grants_changed", + }, + 409 + ) + : error("A run is already active for this automation", 409); } if (triggerError instanceof AutomationExecutionUnauthorizedError) { - return json({ error: "Execution authorization required" }, 403); + return json( + { error: "Execution authorization required", reason_code: triggerError.reason }, + 403 + ); } return error("Failed to trigger automation", 500); } diff --git a/packages/control-plane/src/routes/automation-list.test.ts b/packages/control-plane/src/routes/automation-list.test.ts index cdff68243d..e1bde493d0 100644 --- a/packages/control-plane/src/routes/automation-list.test.ts +++ b/packages/control-plane/src/routes/automation-list.test.ts @@ -103,6 +103,7 @@ describe("automation listing routes", () => { expect(mockStore.list).toHaveBeenCalledWith({ limit: DEFAULT_AUTOMATION_LIST_PAGE_SIZE, cursor: null, + viewer: expect.objectContaining({ kind: "user", userId: "user-1" }), }); expect(mockStore.listRecentExecutionsForAutomationIds).toHaveBeenCalledWith(["auto-1"], 10); expect(body.automations[0]).toMatchObject({ recentExecutions: [] }); @@ -137,6 +138,7 @@ describe("automation listing routes", () => { nameSearch: "Daily sync", limit: 10, cursor: { createdAt: 123, id: "auto-9" }, + viewer: expect.objectContaining({ kind: "user", userId: "user-1" }), }); }); @@ -152,6 +154,7 @@ describe("automation listing routes", () => { cursor: null, repoOwner: "acme", repoName: "web-app", + viewer: expect.objectContaining({ kind: "user", userId: "user-1" }), }); }); diff --git a/packages/control-plane/src/routes/automation-list.ts b/packages/control-plane/src/routes/automation-list.ts index 0f74e0fc7e..b2c841bb6f 100644 --- a/packages/control-plane/src/routes/automation-list.ts +++ b/packages/control-plane/src/routes/automation-list.ts @@ -11,7 +11,8 @@ import type { ControlPlaneHonoEnv } from "../routing/hono-env"; import { type RequestContext, json } from "./shared"; import type { Env } from "../types"; import { z } from "zod"; -import { AUTOMATIONS_READ } from "./automation-shared"; +import { AUTOMATIONS_READ_PERMISSION, automationResponseCapabilities } from "./automation-shared"; +import { resourceViewer } from "../authorization/resource-viewer"; import { parseQuery } from "./query"; import { DEFAULT_AUTOMATION_LIST_PAGE_SIZE, @@ -51,6 +52,11 @@ const automationListQuerySchema = z.object({ .optional(), repoOwner: z.string().optional(), repoName: z.string().optional(), + teamId: z + .string() + .min(1, { error: "Invalid teamId" }) + .optional() + .transform((teamId) => (teamId === "null" ? null : teamId)), }); async function handleListAutomations( @@ -64,14 +70,18 @@ async function handleListAutomations( const store = new AutomationStore(ctx.db); const providerAuthStore = new AutomationModelProviderAuthStore(ctx.db); + const viewer = await resourceViewer(ctx); const result = await store.list({ + viewer, limit: query.limit, cursor: query.cursor, ...(query.search ? { nameSearch: query.search } : {}), ...(query.repoOwner ? { repoOwner: query.repoOwner } : {}), ...(query.repoName ? { repoName: query.repoName } : {}), + ...(query.teamId !== undefined ? { teamId: query.teamId } : {}), }); - const automationIds = result.automations.map((row) => row.id); + const rows = await store.projectCanonicalOwners(result.automations); + const automationIds = rows.map((row) => row.id); const [ repositoriesByAutomation, environmentsByAutomation, @@ -84,13 +94,14 @@ async function handleListAutomations( store.listRecentExecutionsForAutomationIds(automationIds, RECENT_EXECUTION_COUNT), ]); - const automations = result.automations.map((row) => ({ + const automations = rows.map((row) => ({ ...toAutomation( row, repositoriesByAutomation.get(row.id) ?? [], environmentsByAutomation.get(row.id) ?? [], providerAuthByAutomation.get(row.id) ?? [] ), + capabilities: automationResponseCapabilities(viewer, row), recentExecutions: recentExecutionsByAutomation.get(row.id) ?? [], })); return json({ @@ -102,6 +113,6 @@ async function handleListAutomations( export const automationListRoutes = new Hono(); -automationListRoutes.get("/automations", AUTOMATIONS_READ, (c) => +automationListRoutes.get("/automations", AUTOMATIONS_READ_PERMISSION, (c) => dispatch(c, handleListAutomations) ); diff --git a/packages/control-plane/src/routes/automation-runs.test.ts b/packages/control-plane/src/routes/automation-runs.test.ts index ea979aad1f..ec3d32a5f2 100644 --- a/packages/control-plane/src/routes/automation-runs.test.ts +++ b/packages/control-plane/src/routes/automation-runs.test.ts @@ -7,10 +7,18 @@ * supply the principal. */ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type * as AuthenticateModule from "../auth/authenticate"; -import { createTestRequestHandler } from "../router.test-support"; -import { MAX_AUTOMATION_INVOCATION_LIST_LIMIT } from "@open-inspect/shared/types/automations"; +import { createTestRequestHandler, TEST_SESSION_ROW } from "../router.test-support"; +import { + MAX_AUTOMATION_INVOCATION_LIST_LIMIT, + type AutomationRun, + type ListAutomationInvocationsResponse, +} from "@open-inspect/shared/types/automations"; +import { toAutomationRun, type EnrichedRunRow } from "../db/automation-store"; +import { SessionCollaboratorStore } from "../db/session-collaborators"; +import { SessionIndexStore } from "../db/session-index"; +import { toSessionFields, type SessionRow } from "../db/session-row"; import { automationRoutes } from "./automations"; import { DEFAULT_INVOCATION_LIST_LIMIT, MAX_INVOCATION_LIST_OFFSET } from "./automation-runs"; import { @@ -38,7 +46,6 @@ vi.mock("../db/automation-store", async (importOriginal) => { return mockStore; }), toAutomation: vi.fn((row: unknown) => row), - toAutomationRun: vi.fn((row: unknown) => row), }; }); @@ -70,7 +77,35 @@ vi.mock("../db/environments", () => ({ }), })); -const callRoute = automationRequest(createTestRequestHandler([automationRoutes])); +const handleRequest = createTestRequestHandler([automationRoutes]); +const callRoute = automationRequest(handleRequest); +const linkedRunRow: EnrichedRunRow = { + id: "run-1", + automation_id: "auto-1", + invocation_id: "inv-1", + session_id: "session-1", + status: "completed", + skip_reason: null, + failure_reason: null, + scheduled_at: 1000, + started_at: 1100, + execution_deadline_at: 3000, + completed_at: 2000, + created_at: 1000, + repo_owner: "group/subgroup", + repo_name: "app", + repo_id: 42, + base_branch: "main", + environment_id: "env_run_snapshot", + session_title: "Confidential session title", + artifact_summary: "Confidential pull request summary", +}; +const privateSession: SessionRow = { + ...TEST_SESSION_ROW, + title: linkedRunRow.session_title, + user_id: "another-user", + visibility: "private", +}; describe("automation run routes", () => { beforeEach(() => { @@ -171,7 +206,12 @@ describe("automation run routes", () => { describe("GET /automations/:id/runs/:runId (get run)", () => { it("returns a specific run", async () => { - mockStore.getRunById.mockResolvedValue({ id: "run-1", status: "completed" }); + mockStore.getRunById.mockResolvedValue({ + ...linkedRunRow, + session_id: null, + session_title: null, + artifact_summary: null, + }); const res = await callRoute("GET", "/automations/auto-1/runs/run-1"); expect(res.status).toBe(200); @@ -187,4 +227,48 @@ describe("automation run routes", () => { expect(res.status).toBe(404); }); }); + + describe.each([ + { name: "invocation list", path: "/automations/auto-1/invocations" }, + { name: "run item", path: "/automations/auto-1/runs/run-1" }, + ])("linked session privacy on $name", ({ path }) => { + afterEach(() => vi.restoreAllMocks()); + + it("redacts non-null linked metadata without changing the run", async () => { + const run = toAutomationRun(linkedRunRow); + const invocation = { + id: "inv-1", + automationId: "auto-1", + status: "completed" as const, + source: "schedule" as const, + scheduledAt: 1000, + skipReason: null, + createdAt: 1000, + completedAt: 2000, + runs: [run], + }; + mockStore.listInvocations.mockResolvedValue({ + invocations: [{ ...invocation, runs: [{ ...run }] }], + total: 1, + }); + mockStore.getRunById.mockResolvedValue({ ...linkedRunRow }); + vi.spyOn(SessionIndexStore.prototype, "getByIds").mockResolvedValue( + new Map([["session-1", toSessionFields(privateSession)]]) + ); + vi.spyOn(SessionCollaboratorStore.prototype, "listForSessions").mockResolvedValue(new Map()); + const res = await callRoute("GET", path, { + permissions: ["automations.read", "sessions.read"], + }); + + expect(res.status).toBe(200); + const expectedRun = { ...run, sessionId: null, sessionTitle: null, artifactSummary: null }; + if (path.endsWith("/invocations")) { + const body = await res.json(); + expect(body).toEqual({ invocations: [{ ...invocation, runs: [expectedRun] }], total: 1 }); + } else { + const body = await res.json<{ run: AutomationRun }>(); + expect(body).toEqual({ run: expectedRun }); + } + }); + }); }); diff --git a/packages/control-plane/src/routes/automation-runs.ts b/packages/control-plane/src/routes/automation-runs.ts index 2808f6613e..27639a2725 100644 --- a/packages/control-plane/src/routes/automation-runs.ts +++ b/packages/control-plane/src/routes/automation-runs.ts @@ -2,15 +2,22 @@ * Automation invocation and run read routes. */ -import { MAX_AUTOMATION_INVOCATION_LIST_LIMIT } from "@open-inspect/shared/types/automations"; +import { checkSessionAccess } from "@open-inspect/shared"; +import { + MAX_AUTOMATION_INVOCATION_LIST_LIMIT, + type AutomationRun, +} from "@open-inspect/shared/types/automations"; +import { auditPrivateSessionBreakGlass } from "../authorization/request-audit"; import { AutomationStore, toAutomationRun } from "../db/automation-store"; +import { SessionCollaboratorStore } from "../db/session-collaborators"; +import { SessionIndexStore } from "../db/session-index"; import { Hono } from "hono"; import { dispatch } from "../routing/admit"; import type { ControlPlaneHonoEnv } from "../routing/hono-env"; import { type RequestContext, json, error } from "./shared"; import type { Env } from "../types"; import { z } from "zod"; -import { AUTOMATIONS_READ } from "./automation-shared"; +import { admittedAutomation, AUTOMATION_READ } from "./automation-shared"; import { parseQuery } from "./query"; export const DEFAULT_INVOCATION_LIST_LIMIT = 20; @@ -34,6 +41,50 @@ const invocationListQuerySchema = z.object({ .refine((offset) => offset <= MAX_INVOCATION_LIST_OFFSET, { error: "Invalid offset" }), }); +/** + * Session IDs among `runs` the admitted viewer may read. The Owner's private-session + * break-glass applies only to single-run reads, which audit it; lists never enumerate them. + */ +async function readableRunSessionIds( + ctx: RequestContext, + runs: readonly AutomationRun[], + breakGlass: "exclude" | "audit" +): Promise> { + const viewer = admittedAutomation(ctx).viewer; + const sessionIds = [...new Set(runs.flatMap((run) => (run.sessionId ? [run.sessionId] : [])))]; + const [sessions, collaborators] = await Promise.all([ + new SessionIndexStore(ctx.db).getByIds(sessionIds), + new SessionCollaboratorStore(ctx.db).listForSessions(sessionIds), + ]); + const readable = new Set(); + for (const [sessionId, session] of sessions) { + const read = checkSessionAccess( + viewer, + { + id: sessionId, + ownerUserId: session.userId ?? null, + ownerTeamId: session.ownerTeamId, + visibility: session.visibility, + collaboratorIds: collaborators.get(sessionId) ?? [], + }, + "read" + ); + if (!read.allowed) continue; + if (read.audit === "session.private_break_glass") { + if (breakGlass === "exclude") continue; + await auditPrivateSessionBreakGlass(ctx, sessionId, session.ownerTeamId); + } + readable.add(sessionId); + } + return readable; +} + +/** Hide a run's linked-session details unless its session is readable. */ +function redactRunSession(run: AutomationRun, readable: ReadonlySet): AutomationRun { + if (run.sessionId && readable.has(run.sessionId)) return run; + return { ...run, sessionId: null, sessionTitle: null, artifactSummary: null }; +} + /** GET /automations/:id/invocations — one row per firing; `total` counts invocations. */ async function handleListInvocations( request: Request, @@ -46,13 +97,18 @@ async function handleListInvocations( if (query instanceof Response) return query; const store = new AutomationStore(ctx.db); - const automation = await store.getById(automationId); - if (!automation) return error("Automation not found", 404); - const result = await store.listInvocations(automationId, query); + const readable = await readableRunSessionIds( + ctx, + result.invocations.flatMap((invocation) => invocation.runs), + "exclude" + ); return json({ - invocations: result.invocations, + invocations: result.invocations.map((invocation) => ({ + ...invocation, + runs: invocation.runs.map((run) => redactRunSession(run, readable)), + })), total: result.total, }); } @@ -69,14 +125,16 @@ async function handleGetRun( const run = await store.getRunById(automationId, runId); if (!run) return error("Run not found", 404); - return json({ run: toAutomationRun(run) }); + const result = toAutomationRun(run); + const readable = await readableRunSessionIds(ctx, [result], "audit"); + return json({ run: redactRunSession(result, readable) }); } export const automationRunRoutes = new Hono(); -automationRunRoutes.get("/automations/:id/invocations", AUTOMATIONS_READ, (c) => +automationRunRoutes.get("/automations/:id/invocations", AUTOMATION_READ, (c) => dispatch(c, handleListInvocations) ); -automationRunRoutes.get("/automations/:id/runs/:runId", AUTOMATIONS_READ, (c) => +automationRunRoutes.get("/automations/:id/runs/:runId", AUTOMATION_READ, (c) => dispatch(c, handleGetRun) ); diff --git a/packages/control-plane/src/routes/automation-shared.ts b/packages/control-plane/src/routes/automation-shared.ts index cabb4a6ce7..63ffa65b41 100644 --- a/packages/control-plane/src/routes/automation-shared.ts +++ b/packages/control-plane/src/routes/automation-shared.ts @@ -3,6 +3,14 @@ */ import { admit } from "../routing/admit"; +import { + automationCapabilities, + type AutomationCapabilities, + type AutomationView, + type SessionViewer, +} from "@open-inspect/shared"; +import type { AutomationRow } from "../db/automation-store"; +import { hydrateAutomation } from "../automation/hydrate"; import { type RequestContext, GITHUB_USER_OR_SERVICE_ROUTE, @@ -16,9 +24,41 @@ export function admittedAutomation(ctx: RequestContext): AutomationRouteAdmissio return ctx.automationAdmission; } -export const AUTOMATIONS_READ = admit({ +/** What `viewer` may do with the automation stored in `row`. */ +export function automationResponseCapabilities( + viewer: SessionViewer, + row: AutomationRow +): AutomationCapabilities { + return automationCapabilities(viewer, { + ownerTeamId: row.owner_team_id, + executorUserId: row.user_id, + }); +} + +/** The automation response for one viewer. */ +export async function hydrateAutomationResponse( + ctx: RequestContext, + row: AutomationRow, + viewer: SessionViewer +): Promise { + return { + ...(await hydrateAutomation(ctx.db, row)), + capabilities: automationResponseCapabilities(viewer, row), + }; +} + +/** Admission for routes gated only on the `automations.read` permission, not one automation. */ +export const AUTOMATIONS_READ_PERMISSION = admit({ + ...GITHUB_USER_OR_SERVICE_ROUTE, + authorization: requirePermission("automations.read", { + actorlessGrants: [{ service: "slack-bot" }], + }), +}); + +/** Admission for routes that read one automation: hidden automations answer 404. */ +export const AUTOMATION_READ = admit({ ...GITHUB_USER_OR_SERVICE_ROUTE, - authorization: requirePermission("automations.read"), + authorization: requireAutomation("read"), }); /** Admission for routes that change one automation; extend it for per-route response policy. */ diff --git a/packages/control-plane/src/routes/automation-slack-settings.ts b/packages/control-plane/src/routes/automation-slack-settings.ts index 65789c2e0a..d03bf43be5 100644 --- a/packages/control-plane/src/routes/automation-slack-settings.ts +++ b/packages/control-plane/src/routes/automation-slack-settings.ts @@ -15,7 +15,7 @@ import { } from "./shared"; import type { Env } from "../types"; import { createLogger } from "../logger"; -import { AUTOMATIONS_READ } from "./automation-shared"; +import { AUTOMATIONS_READ_PERMISSION } from "./automation-shared"; const logger = createLogger("router:automations"); @@ -85,6 +85,8 @@ automationSlackSettingsRoutes.get( }), (c) => dispatch(c, handleGetWatchedSlackChannels) ); -automationSlackSettingsRoutes.get("/integration-settings/slack/channels", AUTOMATIONS_READ, (c) => - dispatch(c, handleGetSlackChannels) +automationSlackSettingsRoutes.get( + "/integration-settings/slack/channels", + AUTOMATIONS_READ_PERMISSION, + (c) => dispatch(c, handleGetSlackChannels) ); diff --git a/packages/control-plane/src/routes/automation-update.test.ts b/packages/control-plane/src/routes/automation-update.test.ts index ad6ff50fe9..2bd3c2370c 100644 --- a/packages/control-plane/src/routes/automation-update.test.ts +++ b/packages/control-plane/src/routes/automation-update.test.ts @@ -7,8 +7,13 @@ * supply the principal. */ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TeamMembershipStore } from "../db/team-memberships"; +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import { TeamStore } from "../db/teams"; import type * as AuthenticateModule from "../auth/authenticate"; +import type * as AuthorizationGuardModule from "../automation/authorization-guard"; +import { isAutomationExecutionAuthorized } from "../automation/authorization-guard"; import { resolveRepoOrError } from "./shared"; import { PERMISSION_IDS } from "@open-inspect/shared/rbac"; import { createTestRequestHandler } from "../router.test-support"; @@ -94,6 +99,11 @@ vi.mock("../auth/crypto", () => ({ generateId: vi.fn(() => "generated-id"), })); +vi.mock("../automation/authorization-guard", async (importOriginal) => ({ + ...(await importOriginal()), + isAutomationExecutionAuthorized: vi.fn(), +})); + vi.mock("./shared", async (importOriginal) => { const actual = (await importOriginal()) as Record; return { @@ -109,10 +119,29 @@ vi.mock("./shared", async (importOriginal) => { const callRoute = automationRequest(createTestRequestHandler([automationRoutes])); +/** Selected environments must share the automation's owner team. */ +function ownEnvironmentsByTeamAlpha(): void { + mockEnvironmentStore.getById.mockImplementation(async (id: string) => ({ + id, + name: id, + owner_team_id: "team_alpha", + })); +} + +/** Callers without the built-in owner role are admitted to team automations only as members. */ +function joinTeamAlpha(): void { + vi.spyOn(TeamMembershipStore.prototype, "listForUser").mockResolvedValue( + new Map([["team_alpha", "member"]]) + ); +} + describe("automation read, update, and delete routes", () => { + afterEach(() => vi.restoreAllMocks()); beforeEach(() => { vi.clearAllMocks(); applyMockDefaults(); + vi.spyOn(TeamStore.prototype, "isActive").mockResolvedValue(true); + vi.mocked(isAutomationExecutionAuthorized).mockResolvedValue(true); vi.mocked(resolveRepoOrError).mockResolvedValue({ repoId: 12345, repoOwner: "acme", @@ -141,6 +170,25 @@ describe("automation read, update, and delete routes", () => { }); describe("PUT /automations/:id (update)", () => { + it("refuses target edits the current executor could not launch", async () => { + mockStore.getById.mockResolvedValue({ ...sampleRow, user_id: "executor-1" }); + vi.mocked(isAutomationExecutionAuthorized).mockResolvedValue(false); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { repositories: [{ repoOwner: "acme", repoName: "web-app" }] }, + }); + + expect(res.status).toBe(409); + expect(await res.json()).toMatchObject({ code: "automation_executor_unauthorized" }); + expect(isAutomationExecutionAuthorized).toHaveBeenCalledWith(expect.anything(), { + automationId: "auto-1", + executionUserId: "executor-1", + requiresRepositoryUse: true, + requiresEnvironmentUse: false, + }); + expect(mockBatch).not.toHaveBeenCalled(); + }); + it.each([ ["repository", { repositories: [] }, "repositories.use"], ["environment", { environmentIds: [] }, "environments.use"], @@ -183,9 +231,249 @@ describe("automation read, update, and delete routes", () => { permission, }); expect(mockBatch).not.toHaveBeenCalled(); + expect(resolveRepoOrError).not.toHaveBeenCalled(); + expect(mockEnvironmentStore.getById).not.toHaveBeenCalled(); + expect(mockEnvironmentStore.getRepositoriesForEnvironment).not.toHaveBeenCalled(); + } + ); + + it("denies a repository replacement not granted to the persisted owner team", async () => { + mockStore.getById.mockResolvedValue({ ...sampleRow, owner_team_id: "team_alpha" }); + const grants = vi + .spyOn(TeamRepositoryGrantStore.prototype, "listForTeam") + .mockResolvedValue([]); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { repositories: [{ repoOwner: "acme", repoName: "api" }] }, + }); + + expect(res.status).toBe(409); + await expect(res.json()).resolves.toEqual({ + error: "Target team lacks repository grant", + code: "target_team_missing_grant", + repository: "acme/api", + }); + expect(grants).toHaveBeenCalledWith("team_alpha"); + expect(mockStore.bindAutomationUpdate).not.toHaveBeenCalled(); + expect(mockStore.bindReplaceRepositories).not.toHaveBeenCalled(); + expect(mockBatch).not.toHaveBeenCalled(); + }); + + it.each([false, true])( + "denies an ungranted repository in a later replacement environment (mixed targets: %s)", + async (mixedTargets) => { + mockStore.getById.mockResolvedValue({ ...sampleRow, owner_team_id: "team_alpha" }); + ownEnvironmentsByTeamAlpha(); + mockEnvironmentStore.getRepositoriesForEnvironment.mockImplementation( + async (id: string) => [ + { + environment_id: id, + position: 0, + repo_owner: "acme", + repo_name: id === "env_granted" ? "web" : "api", + repo_id: id === "env_granted" ? 1 : 2, + base_branch: "main", + }, + ] + ); + const grants = vi + .spyOn(TeamRepositoryGrantStore.prototype, "listForTeam") + .mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 1 }, + { grant_kind: "repository", repo_external_id: 12345 }, + ]); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { + name: "Updated", + environmentIds: ["env_granted", "env_ungranted"], + ...(mixedTargets ? { repositories: [{ repoOwner: "acme", repoName: "app" }] } : {}), + }, + }); + + expect(res.status).toBe(409); + await expect(res.json()).resolves.toMatchObject({ + code: "target_team_missing_grant", + repository: "acme/api", + }); + expect(mockEnvironmentStore.getRepositoriesForEnvironment).toHaveBeenCalledWith( + "env_granted" + ); + expect(mockEnvironmentStore.getRepositoriesForEnvironment).toHaveBeenCalledWith( + "env_ungranted" + ); + expect(grants).toHaveBeenCalledWith("team_alpha"); + expect(mockStore.bindAutomationUpdate).not.toHaveBeenCalled(); + expect(mockStore.bindReplaceRepositories).not.toHaveBeenCalled(); + expect(mockStore.bindReplaceEnvironments).not.toHaveBeenCalled(); + expect(mockBatch).not.toHaveBeenCalled(); + } + ); + + it.each([ + { repoId: 7, grant: { grant_kind: "repository", repo_external_id: 7 } as const }, + { repoId: null, grant: { grant_kind: "installation", repo_external_id: null } as const }, + ])( + "accepts granted environment repositories without repositories.use", + async ({ repoId, grant }) => { + mockStore.getById.mockResolvedValue({ ...sampleRow, owner_team_id: "team_alpha" }); + ownEnvironmentsByTeamAlpha(); + joinTeamAlpha(); + mockEnvironmentStore.getRepositoriesForEnvironment.mockResolvedValue([ + { + environment_id: "env_1", + position: 0, + repo_owner: "acme", + repo_name: "app", + repo_id: repoId, + base_branch: "main", + }, + ]); + const grants = vi + .spyOn(TeamRepositoryGrantStore.prototype, "listForTeam") + .mockResolvedValue([grant]); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { environmentIds: ["env_1"] }, + permissions: PERMISSION_IDS.filter((permission) => permission !== "repositories.use"), + }); + + expect(res.status).toBe(200); + expect(mockEnvironmentStore.getRepositoriesForEnvironment).toHaveBeenCalledWith("env_1"); + expect(grants).toHaveBeenCalledWith("team_alpha"); + expect(resolveRepoOrError).not.toHaveBeenCalled(); + expect(mockStore.bindReplaceEnvironments).toHaveBeenCalledWith( + "auto-1", + ["env_1"], + expect.any(Number) + ); + expect(mockBatch).toHaveBeenCalled(); } ); + it("requires an installation grant for an unresolved replacement environment repository", async () => { + mockStore.getById.mockResolvedValue({ ...sampleRow, owner_team_id: "team_alpha" }); + ownEnvironmentsByTeamAlpha(); + mockEnvironmentStore.getRepositoriesForEnvironment.mockResolvedValue([ + { + environment_id: "env_1", + position: 0, + repo_owner: "acme", + repo_name: "app", + repo_id: null, + base_branch: "main", + }, + ]); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 7 }, + ]); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { environmentIds: ["env_1"] }, + }); + + expect(res.status).toBe(409); + await expect(res.json()).resolves.toMatchObject({ + code: "target_team_missing_grant", + repository: "acme/app", + }); + expect(mockBatch).not.toHaveBeenCalled(); + }); + + it("leaves workspace-level automation environment replacements unscoped", async () => { + const grants = vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam"); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { environmentIds: ["env_1"] }, + }); + + expect(res.status).toBe(200); + expect(mockEnvironmentStore.getRepositoriesForEnvironment).not.toHaveBeenCalled(); + expect(grants).not.toHaveBeenCalled(); + expect(mockBatch).toHaveBeenCalled(); + }); + + it.each([{ environmentIds: ["env_1"] }, { environmentIds: [] }])( + "validates unchanged repositories against team grants when environment targets change", + async ({ environmentIds }) => { + mockStore.getById.mockResolvedValue({ ...sampleRow, owner_team_id: "team_alpha" }); + ownEnvironmentsByTeamAlpha(); + joinTeamAlpha(); + mockStore.getRepositoriesForAutomation.mockResolvedValue([ + { repo_owner: "acme", repo_name: "app", repo_id: 7, base_branch: "main" }, + ]); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([]); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { environmentIds }, + permissions: PERMISSION_IDS.filter((permission) => permission !== "repositories.use"), + }); + + expect(res.status).toBe(409); + await expect(res.json()).resolves.toMatchObject({ + code: "target_team_missing_grant", + repository: "acme/app", + }); + expect(resolveRepoOrError).not.toHaveBeenCalled(); + expect(mockStore.bindAutomationUpdate).not.toHaveBeenCalled(); + expect(mockStore.bindReplaceEnvironments).not.toHaveBeenCalled(); + expect(mockBatch).not.toHaveBeenCalled(); + } + ); + + it.each([ + { + repoId: 7, + grant: { grant_kind: "repository", repo_external_id: 7 } as const, + }, + { + repoId: null, + grant: { grant_kind: "installation", repo_external_id: null } as const, + }, + ])( + "clears environments without new use permissions for granted unchanged repositories", + async ({ repoId, grant }) => { + mockStore.getById.mockResolvedValue({ ...sampleRow, owner_team_id: "team_alpha" }); + joinTeamAlpha(); + mockStore.getRepositoriesForAutomation.mockResolvedValue([ + { repo_owner: "acme", repo_name: "app", repo_id: repoId, base_branch: "main" }, + ]); + const grants = vi + .spyOn(TeamRepositoryGrantStore.prototype, "listForTeam") + .mockResolvedValue([grant]); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { environmentIds: [] }, + permissions: PERMISSION_IDS.filter( + (permission) => permission !== "repositories.use" && permission !== "environments.use" + ), + }); + + expect(res.status).toBe(200); + expect(TeamStore.prototype.isActive).toHaveBeenCalledWith("team_alpha"); + expect(grants).toHaveBeenCalledWith("team_alpha"); + expect(resolveRepoOrError).not.toHaveBeenCalled(); + expect(mockStore.bindReplaceRepositories).not.toHaveBeenCalled(); + expect(mockStore.bindReplaceEnvironments).toHaveBeenCalledWith( + "auto-1", + [], + expect.any(Number) + ); + expect(mockBatch).toHaveBeenCalled(); + } + ); + + it("does not revalidate unchanged targets for an unrelated team automation edit", async () => { + mockStore.getById.mockResolvedValue({ ...sampleRow, owner_team_id: "team_alpha" }); + const grants = vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam"); + + const res = await callRoute("PUT", "/automations/auto-1", { body: { name: "Updated" } }); + + expect(res.status).toBe(200); + expect(grants).not.toHaveBeenCalled(); + expect(mockBatch).toHaveBeenCalled(); + }); + it("updates automation fields", async () => { mockStore.getById.mockResolvedValue(sampleRow); diff --git a/packages/control-plane/src/routes/automation-validation.test.ts b/packages/control-plane/src/routes/automation-validation.test.ts new file mode 100644 index 0000000000..0fe169cc20 --- /dev/null +++ b/packages/control-plane/src/routes/automation-validation.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createRequestMetrics } from "../db/instrumented-sql-database"; +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import { TeamStore } from "../db/teams"; +import { createTestEnv, TEST_BACKGROUND_TASK_CONTEXT } from "../router.test-support"; +import { resolveRepoOrError, type RequestContext } from "./shared"; +import type * as SharedRoutes from "./shared"; +import { resolveRepositorySelection } from "./automation-validation"; + +vi.mock("./shared", async (importOriginal) => ({ + ...(await importOriginal()), + resolveRepoOrError: vi.fn(), +})); + +const env = createTestEnv(); +const repositories = [{ repoOwner: "acme", repoName: "app", baseBranch: null }]; + +function context(): RequestContext { + return { + request_id: "request-1", + trace_id: "trace-1", + metrics: createRequestMetrics(), + executionCtx: TEST_BACKGROUND_TASK_CONTEXT, + db: env.DB, + principal: { kind: "user", userId: "user-1" }, + authorization: { + userId: "user-1", + suspendedAt: null, + role: { id: "role-1", key: null, name: "Test" }, + permissions: ["repositories.use"], + }, + }; +} + +describe("resolveRepositorySelection target authorization", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(TeamStore.prototype, "isActive").mockResolvedValue(true); + vi.mocked(resolveRepoOrError).mockResolvedValue({ + repoId: 7, + repoOwner: "acme", + repoName: "app", + defaultBranch: "main", + }); + }); + afterEach(() => vi.restoreAllMocks()); + + it("checks user target permission before SCM resolution", async () => { + const ctx = context(); + if (ctx.authorization) ctx.authorization.permissions = []; + + const result = await resolveRepositorySelection(env, repositories, ctx, null); + + expect(result).toBeInstanceOf(Response); + if (!(result instanceof Response)) throw new Error("Expected target denial"); + expect(result.status).toBe(403); + await expect(result.json()).resolves.toMatchObject({ permission: "repositories.use" }); + expect(resolveRepoOrError).not.toHaveBeenCalled(); + }); + + it("checks service actor target permission before SCM resolution", async () => { + const ctx = context(); + ctx.principal = { kind: "service", service: "slack-bot", actor: null }; + if (ctx.authorization) ctx.authorization.permissions = []; + + const result = await resolveRepositorySelection(env, repositories, ctx, null); + + expect(result).toBeInstanceOf(Response); + if (!(result instanceof Response)) throw new Error("Expected target denial"); + expect(result.status).toBe(403); + expect(resolveRepoOrError).not.toHaveBeenCalled(); + }); + + it("checks the resolved ID against the target team's grants", async () => { + const covers = vi.spyOn(TeamRepositoryGrantStore.prototype, "covers").mockResolvedValue(false); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([]); + + const result = await resolveRepositorySelection(env, repositories, context(), "team_alpha"); + + expect(result).toBeInstanceOf(Response); + if (!(result instanceof Response)) throw new Error("Expected target denial"); + expect(result.status).toBe(409); + await expect(result.json()).resolves.toEqual({ + error: "Target team lacks repository grant", + code: "target_team_missing_grant", + repository: "acme/app", + }); + expect(covers).toHaveBeenCalledWith("team_alpha", [7]); + }); + + it("keeps workspace-owned selections without team grant lookups", async () => { + const grants = vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam"); + + await expect(resolveRepositorySelection(env, repositories, context(), null)).resolves.toEqual([ + { repo_owner: "acme", repo_name: "app", repo_id: 7, base_branch: "main" }, + ]); + expect(grants).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/control-plane/src/routes/automation-validation.ts b/packages/control-plane/src/routes/automation-validation.ts index 2e5c7175cf..61d59a26c5 100644 --- a/packages/control-plane/src/routes/automation-validation.ts +++ b/packages/control-plane/src/routes/automation-validation.ts @@ -8,11 +8,16 @@ import { createAutomationRequestSchema, validateAutomationTargetCounts, } from "@open-inspect/shared/types/automations"; -import type { PermissionId } from "@open-inspect/shared/rbac"; import { isValidReasoningEffort } from "@open-inspect/shared/models"; import { type AutomationRepositoryInsert } from "../db/automation-store"; import { EnvironmentStore } from "../db/environments"; -import { type RequestContext, json, resolveRepoOrError } from "./shared"; +import { TeamMembershipStore } from "../db/team-memberships"; +import { checkEnvironmentAccess, type SessionViewer } from "@open-inspect/shared"; +import { type RequestContext, error, json, resolveRepoOrError } from "./shared"; +import type { RepositoryAuthorizationTarget } from "./workspace-repository-authorization"; +import { resolveActiveTeam } from "./team-ownership"; +import { automationActionDeniedBody } from "../authorization/owned-resource-admission"; +import { authorizeSessionTarget } from "./session-target-authorization"; import type { Env } from "../types"; import type { SqlDatabase } from "../db/sql-database"; import { z } from "zod"; @@ -20,24 +25,6 @@ import { createLogger } from "../logger"; const logger = createLogger("router:automations"); -export function requireTargetPermissions( - ctx: RequestContext, - requiredPermissions: readonly PermissionId[] -): Response | null { - const authorization = ctx.authorization; - if (!authorization) return json({ error: "Authorization unavailable" }, 503); - const missingPermission = requiredPermissions.find( - (permission) => !authorization.permissions.includes(permission) - ); - if (missingPermission) { - return json( - { error: "Forbidden", code: "permission_required", permission: missingPermission }, - 403 - ); - } - return null; -} - export const createAutomationBodySchema = createAutomationRequestSchema.extend({ // Bot-asserted actor display fields are cosmetic only; identity enforcement // still runs against the raw pre-Zod body before these parsed values are used. @@ -133,14 +120,81 @@ type RepositorySelectionRequest = | { kind: "replace"; repositories: NormalizedRepositoryInput[] }; /** - * Thrown when selection semantics cannot be satisfied. Route handlers catch it - * and answer 400 while request shape validation remains in the shared schemas. + * Selection validation failures carry their HTTP status; request shape + * validation remains in the shared schemas. */ export class TargetSelectionError extends Error { - constructor(message: string) { + constructor( + message: string, + readonly status: 400 | 403 | 409 = 400, + readonly reasonCode?: string + ) { super(message); this.name = "TargetSelectionError"; } + + response(): Response { + return json( + { + error: this.message, + ...(this.reasonCode ? { code: this.reasonCode, reason_code: this.reasonCode } : {}), + }, + this.status + ); + } +} + +/** An automation's team, when it has one, must be active and include its executor. */ +export async function validateAutomationTeam( + ctx: RequestContext, + teamId: string | null, + executorUserId: string | null +): Promise { + if (teamId === null) return null; + const team = await resolveActiveTeam(ctx, teamId); + if (team instanceof Response) return team; + return validateTeamExecutor(ctx.db, teamId, executorUserId); +} + +/** A team-owned automation's executor must be a canonical member of that team. */ +export async function validateTeamExecutor( + db: SqlDatabase, + teamId: string, + executorUserId: string | null +): Promise { + if (!executorUserId) { + return json({ error: "Canonical executor required", code: "executor_required" }, 409); + } + if (!(await new TeamMembershipStore(db).listForUser(executorUserId)).has(teamId)) { + return json(automationActionDeniedBody("not_member", "Executor must belong to the team"), 403); + } + return null; +} + +export async function validateAutomationExecutor( + db: SqlDatabase, + userId: string +): Promise { + const user = z + .object({ suspended_at: z.number().nullable(), role_id: z.string().nullable() }) + .nullable() + .parse( + await db + .prepare( + `SELECT u.suspended_at, a.role_id FROM users u + LEFT JOIN user_role_assignments a ON a.user_id = u.id WHERE u.id = ?` + ) + .bind(userId) + .first() + ); + if (!user) return error("User not found", 404); + if (user.suspended_at !== null || user.role_id === null) { + return json( + { error: "User inactive", code: "user_inactive", reason_code: "user_inactive" }, + 409 + ); + } + return null; } /** @@ -191,22 +245,63 @@ export function getEnvironmentSelection(body: { } /** - * Verify every selected environment exists — a selection must not silently - * point at deleted environments. + * Verify selected environments are visible, belong to the automation's team, and + * admit use for replacements. Stored selections still supply the owning team's grant check. * - * @throws TargetSelectionError naming every missing environment. + * @throws TargetSelectionError naming every missing or invisible environment. */ export async function resolveEnvironmentSelection( db: SqlDatabase, - environmentIds: string[] -): Promise { - if (environmentIds.length === 0) return; + environmentIds: string[], + ownerTeamId: string | null, + viewer: SessionViewer, + requireUse = true +): Promise { + if (environmentIds.length === 0) return []; const store = new EnvironmentStore(db); - const found = await Promise.all(environmentIds.map((id) => store.getById(id))); + const found = await Promise.all( + environmentIds.map(async (id) => { + const environment = await store.getById(id); + if (!environment) return null; + const access = checkEnvironmentAccess( + viewer, + { ownerTeamId: environment.owner_team_id }, + "use" + ); + if (!access.allowed && access.reason === "not_member") return null; + return { environment, access }; + }) + ); const missing = environmentIds.filter((_, index) => !found[index]); if (missing.length > 0) { throw new TargetSelectionError(`Environment not found: ${missing.join(", ")}`); } + const repositories: RepositoryAuthorizationTarget[] = []; + for (const target of found) { + if (!target) continue; + const { environment, access } = target; + // Unchanged selections retain their use-permission exemption, not a visibility exemption. + if (!access.allowed && (requireUse || access.reason !== "missing_permission")) { + throw new TargetSelectionError("Environment use denied", 403, access.reason); + } + if (environment.owner_team_id !== ownerTeamId) { + throw new TargetSelectionError( + "Environment must belong to the automation's owner team", + 409, + "environment_team_mismatch" + ); + } + if (ownerTeamId !== null) { + repositories.push( + ...(await store.getRepositoriesForEnvironment(environment.id)).map((repository) => ({ + owner: repository.repo_owner, + name: repository.repo_name, + repoId: repository.repo_id, + })) + ); + } + } + return repositories; } /** @@ -217,8 +312,18 @@ export async function resolveEnvironmentSelection( export async function resolveRepositorySelection( env: Env, repositories: NormalizedRepositoryInput[], - ctx: RequestContext -): Promise { + ctx: RequestContext, + teamId: string | null +): Promise { + const targetAuthorizationError = await authorizeSessionTarget(ctx, { + teamId: null, + repositories: repositories.map((repository) => ({ + owner: repository.repoOwner, + name: repository.repoName, + })), + }); + if (targetAuthorizationError) return targetAuthorizationError; + const settled = await Promise.allSettled( repositories.map((repository) => resolveRepoOrError(env, repository.repoOwner, repository.repoName, ctx, logger) @@ -229,7 +334,7 @@ export async function resolveRepositorySelection( return result.value; }); - return repositories.map((repository, index) => { + const inserts = repositories.map((repository, index) => { const access = resolved[index]; return { repo_owner: repository.repoOwner, @@ -238,6 +343,15 @@ export async function resolveRepositorySelection( base_branch: repository.baseBranch ?? access.defaultBranch, }; }); + const resolvedTargetAuthorizationError = await authorizeSessionTarget(ctx, { + teamId, + repositories: inserts.map((repository) => ({ + owner: repository.repo_owner, + name: repository.repo_name, + repoId: repository.repo_id, + })), + }); + return resolvedTargetAuthorizationError ?? inserts; } /** Extract the watched channel IDs from a slack automation's `slack_channel` condition. */ diff --git a/packages/control-plane/src/routes/automations.test-support.ts b/packages/control-plane/src/routes/automations.test-support.ts index 017ffcfae8..7b5a1c9802 100644 --- a/packages/control-plane/src/routes/automations.test-support.ts +++ b/packages/control-plane/src/routes/automations.test-support.ts @@ -29,6 +29,7 @@ export const mockStore = { list: vi.fn(), getById: vi.fn(), resolveCanonicalOwner: vi.fn(async (automation: unknown) => automation), + projectCanonicalOwners: vi.fn(async (rows: unknown) => rows), update: vi.fn(), softDelete: vi.fn(), pause: vi.fn(), @@ -69,6 +70,7 @@ export const mockUserStore = { export const mockEnvironmentStore = { getById: vi.fn(), + getRepositoriesForEnvironment: vi.fn(), }; /** Shared D1 batch spy — createEnv wires it as env.DB.batch. */ @@ -176,6 +178,7 @@ export const sampleRow = { consecutive_failures: 0, created_by: "user-1", user_id: "user-1", + owner_team_id: null, created_at: now, updated_at: now, deleted_at: null, @@ -215,7 +218,12 @@ export function applyMockDefaults(): void { invocationId: "inv-1", runs: [{ id: "run-1" }], }); - mockEnvironmentStore.getById.mockResolvedValue({ id: "env_1", name: "Fullstack" }); + mockEnvironmentStore.getById.mockResolvedValue({ + id: "env_1", + name: "Fullstack", + owner_team_id: null, + }); + mockEnvironmentStore.getRepositoriesForEnvironment.mockResolvedValue([]); mockProviderAccountStore.getById.mockResolvedValue({ id: "0123456789abcdef0123456789abcdef", provider: "openai", diff --git a/packages/control-plane/src/routes/automations.ts b/packages/control-plane/src/routes/automations.ts index 58c7bedc17..63bcbc272a 100644 --- a/packages/control-plane/src/routes/automations.ts +++ b/packages/control-plane/src/routes/automations.ts @@ -5,6 +5,7 @@ import { Hono } from "hono"; import type { ControlPlaneHonoEnv } from "../routing/hono-env"; import { automationCrudRoutes } from "./automation-crud"; +import { automationExecutorRoutes } from "./automation-executor"; import { automationKeyRoutes } from "./automation-keys"; import { automationLifecycleRoutes } from "./automation-lifecycle"; import { automationListRoutes } from "./automation-list"; @@ -16,6 +17,7 @@ for (const module of [ automationSlackSettingsRoutes, automationListRoutes, automationCrudRoutes, + automationExecutorRoutes, automationLifecycleRoutes, automationRunRoutes, automationKeyRoutes, diff --git a/packages/control-plane/src/routes/environment-secrets.repository-grants.test.ts b/packages/control-plane/src/routes/environment-secrets.repository-grants.test.ts new file mode 100644 index 0000000000..2000974058 --- /dev/null +++ b/packages/control-plane/src/routes/environment-secrets.repository-grants.test.ts @@ -0,0 +1,480 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BUILT_IN_ROLE_REGISTRY } from "@open-inspect/shared/rbac"; +import { + authorizationDatabase, + createRepositoryGrantEnv, + createRepositoryGrantRequest, + setupRepositoryGrantSpies, +} from "./repository-grants.test-support"; +import { AuthorizationStore } from "../db/authorization-store"; +import { EnvironmentStore } from "../db/environments"; +import { EnvironmentSecretsStore } from "../db/environment-secrets"; +import { TeamMembershipStore } from "../db/team-memberships"; +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import type * as SourceControlModule from "../source-control"; +import type { Env } from "../types"; +import { environmentSecretsRoutes } from "./environment-secrets"; + +const mocks = vi.hoisted(() => ({ + checkRepositoryAccess: vi.fn(), + scheduleImageBuildOnSave: vi.fn(), + supersedeImageBuildsForSecretsChange: vi.fn(), +})); +vi.mock("../source-control", async (importOriginal) => ({ + ...(await importOriginal()), + createSourceControlProviderFromEnv: () => ({ + name: "github", + checkRepositoryAccess: mocks.checkRepositoryAccess, + }), +})); +vi.mock("../image-builds/save-hooks", () => ({ + scheduleImageBuildOnSave: mocks.scheduleImageBuildOnSave, + supersedeImageBuildsForSecretsChange: mocks.supersedeImageBuildsForSecretsChange, +})); + +const env = () => createRepositoryGrantEnv(["environments.secrets.manage", "environments.manage"]); +const request = createRepositoryGrantRequest(environmentSecretsRoutes, env); +const path = "/environments/env-1/secrets/import"; +const body = { repoOwner: "acme", repoName: "repo" }; +const destination = { + id: "env-1", + owner_team_id: "owner-team", + name: "Environment", + description: null, + prebuild_enabled: 0, + channel_associations: null, + created_at: 1, + updated_at: 1, +}; +const sourceRepository = { + environment_id: "env-1", + position: 0, + repo_owner: "acme", + repo_name: "repo", + repo_id: 123, + base_branch: "main", +}; + +beforeEach(() => { + vi.clearAllMocks(); + setupRepositoryGrantSpies(); + // Admit the caller to manage the default team-owned destination. + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["owner-team", "lead"]]) + ); + mocks.checkRepositoryAccess.mockResolvedValue({ + repoId: 123, + repoOwner: "acme", + repoName: "repo", + defaultBranch: "main", + }); + mocks.supersedeImageBuildsForSecretsChange.mockResolvedValue(undefined); + vi.spyOn(EnvironmentStore.prototype, "getById").mockResolvedValue(destination); + vi.spyOn(EnvironmentStore.prototype, "getRepositoriesForEnvironment").mockResolvedValue([ + sourceRepository, + ]); + vi.spyOn(EnvironmentSecretsStore.prototype, "importFromRepo").mockResolvedValue({ + keys: ["KEY"], + created: 1, + updated: 0, + }); +}); +afterEach(() => vi.restoreAllMocks()); + +describe("environment secret import source grants", () => { + it("denies a granted source without a granting-team lead (destination owner null)", async () => { + vi.mocked(EnvironmentStore.prototype.getById).mockResolvedValue({ + ...destination, + owner_team_id: null, + prebuild_enabled: 1, + }); + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["team-1", "lead"]]) + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockImplementation(async (teamId) => + teamId === "owner-team" ? [{ grant_kind: "repository", repo_external_id: 123 }] : [] + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "other-team", + "owner-team", + ]); + + const response = await request(path, "POST", body); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + error: "Repository grant required", + code: "repository_grant_required", + reason_code: "repository_grant_required", + repository: "acme/repo", + }); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).toHaveBeenCalledWith(123); + expect(EnvironmentSecretsStore.prototype.importFromRepo).not.toHaveBeenCalled(); + expect(mocks.supersedeImageBuildsForSecretsChange).not.toHaveBeenCalled(); + expect(mocks.scheduleImageBuildOnSave).not.toHaveBeenCalled(); + }); + + // Managing a team-owned destination requires its lead, and a destination team that covers + // the source is itself a granting team, so a non-lead member is now stopped at admission. + it("denies a non-lead destination member before source grants (destination owner owner-team)", async () => { + vi.mocked(EnvironmentStore.prototype.getById).mockResolvedValue({ + ...destination, + prebuild_enabled: 1, + }); + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["owner-team", "member"]]) + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockImplementation(async (teamId) => + teamId === "owner-team" ? [{ grant_kind: "repository", repo_external_id: 123 }] : [] + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "other-team", + "owner-team", + ]); + + const response = await request(path, "POST", body); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + error: "Forbidden", + code: "environment_action_denied", + reason_code: "not_owner_or_lead", + }); + expect(mocks.checkRepositoryAccess).not.toHaveBeenCalled(); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).not.toHaveBeenCalled(); + expect(EnvironmentSecretsStore.prototype.importFromRepo).not.toHaveBeenCalled(); + expect(mocks.supersedeImageBuildsForSecretsChange).not.toHaveBeenCalled(); + expect(mocks.scheduleImageBuildOnSave).not.toHaveBeenCalled(); + }); + + it("denies an ungranted member source even when the source belongs to the environment", async () => { + expect((await request(path, "POST", body)).status).toBe(409); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).not.toHaveBeenCalled(); + expect(EnvironmentSecretsStore.prototype.importFromRepo).not.toHaveBeenCalled(); + expect(mocks.supersedeImageBuildsForSecretsChange).not.toHaveBeenCalled(); + }); + + it("uses the current team's grant, not another caller team's grant", async () => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([ + ["team-1", "member"], + ["owner-team", "lead"], + ]) + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockImplementation(async (teamId) => + teamId === "team-1" ? [{ grant_kind: "repository", repo_external_id: 123 }] : [] + ); + expect((await request(path, "POST", body)).status).toBe(409); + expect(EnvironmentSecretsStore.prototype.importFromRepo).not.toHaveBeenCalled(); + }); + + it("checks membership before repository resolution", async () => { + expect((await request(path, "POST", { repoOwner: "acme", repoName: "other" })).status).toBe( + 403 + ); + expect(mocks.checkRepositoryAccess).not.toHaveBeenCalled(); + }); + + it.each([123, null])( + "allows any source granting-team lead with only environment secret and manage permissions (%s)", + async (repoId) => { + // Workspace-owned so admission does not require leading the destination's granting team. + vi.mocked(EnvironmentStore.prototype.getById).mockResolvedValue({ + ...destination, + owner_team_id: null, + }); + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([ + ["owner-team", "member"], + ["other-team", "lead"], + ]) + ); + vi.mocked(EnvironmentStore.prototype.getRepositoriesForEnvironment).mockResolvedValue([ + { ...sourceRepository, repo_id: repoId }, + ]); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockImplementation( + async (teamId) => + teamId === "owner-team" ? [{ grant_kind: "repository", repo_external_id: 123 }] : [] + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "owner-team", + "other-team", + ]); + const environment = env(); + environment.DB = authorizationDatabase({ + permissions: ["environments.secrets.manage", "environments.manage"], + }); + expect((await request(path, "POST", body, environment)).status).toBe(200); + expect(EnvironmentSecretsStore.prototype.importFromRepo).toHaveBeenCalledWith( + "env-1", + 123, + undefined + ); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).toHaveBeenCalledWith(123); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledOnce(); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledWith({ owner: "acme", name: "repo" }); + } + ); + + it("does not grant import access based on a matching repository name with a different ID", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 456 }, + ]); + expect((await request(path, "POST", body)).status).toBe(409); + expect(EnvironmentSecretsStore.prototype.importFromRepo).not.toHaveBeenCalled(); + }); +}); + +describe.each(["off", "shadow", "on"] as const)( + "environment secret source identity in %s mode", + (mode) => { + let environment: Env; + beforeEach(() => { + environment = env(); + environment.TEAMS_ENFORCEMENT = mode; + vi.mocked(EnvironmentStore.prototype.getById).mockResolvedValue({ + ...destination, + prebuild_enabled: 1, + }); + }); + + it.each([ + ["custom", 123], + ["custom", 456], + ["owner", 123], + ["owner", 456], + ["administrator", 123], + ["administrator", 456], + ] as const)( + "rejects stored ID 123 resolving to 456 for %s with a grant for %s", + async (role, grantedRepoId) => { + if (role !== "custom") { + vi.spyOn(AuthorizationStore.prototype, "getEffectiveAuthorization").mockResolvedValue({ + userId: "user-1", + suspendedAt: null, + role: { ...BUILT_IN_ROLE_REGISTRY[role], name: role }, + }); + } + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + role === "custom" ? new Map([["owner-team", "lead"]]) : new Map() + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "repository", repo_external_id: grantedRepoId }, + ]); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "owner-team", + ]); + mocks.checkRepositoryAccess.mockResolvedValue({ + repoId: 456, + repoOwner: "acme", + repoName: "repo", + defaultBranch: "main", + }); + + const response = await request(path, "POST", body, environment); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + code: "repository_identity_mismatch", + repository: "acme/repo", + }); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledWith({ owner: "acme", name: "repo" }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).not.toHaveBeenCalled(); + expect(EnvironmentSecretsStore.prototype.importFromRepo).not.toHaveBeenCalled(); + expect(mocks.supersedeImageBuildsForSecretsChange).not.toHaveBeenCalled(); + expect(mocks.scheduleImageBuildOnSave).not.toHaveBeenCalled(); + } + ); + + it.each(["custom", "owner", "administrator"] as const)( + "rejects a stale ID in a workspace-owned environment for %s without team membership", + async (role) => { + if (role !== "custom") { + vi.spyOn(AuthorizationStore.prototype, "getEffectiveAuthorization").mockResolvedValue({ + userId: "user-1", + suspendedAt: null, + role: { ...BUILT_IN_ROLE_REGISTRY[role], name: role }, + }); + } + vi.mocked(EnvironmentStore.prototype.getById).mockResolvedValue({ + ...destination, + owner_team_id: null, + prebuild_enabled: 1, + }); + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue(new Map()); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([]); + mocks.checkRepositoryAccess.mockResolvedValue({ + repoId: 456, + repoOwner: "acme", + repoName: "repo", + defaultBranch: "main", + }); + + const response = await request(path, "POST", body, environment); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + code: "repository_identity_mismatch", + repository: "acme/repo", + }); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledOnce(); + // Only admission's memoized membership snapshot. + expect(TeamMembershipStore.prototype.listForUser).toHaveBeenCalledOnce(); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).not.toHaveBeenCalled(); + expect(EnvironmentSecretsStore.prototype.importFromRepo).not.toHaveBeenCalled(); + expect(mocks.supersedeImageBuildsForSecretsChange).not.toHaveBeenCalled(); + expect(mocks.scheduleImageBuildOnSave).not.toHaveBeenCalled(); + } + ); + + it.each([123, null])( + "returns 404 when the member repository disappears (persisted ID %s)", + async (repoId) => { + vi.mocked(EnvironmentStore.prototype.getRepositoriesForEnvironment).mockResolvedValue([ + { ...sourceRepository, repo_id: repoId }, + ]); + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["owner-team", "lead"]]) + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 123 }, + ]); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "owner-team", + ]); + mocks.checkRepositoryAccess.mockResolvedValue(null); + + const response = await request(path, "POST", body, environment); + + expect(response.status).toBe(404); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledOnce(); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).not.toHaveBeenCalled(); + expect(EnvironmentSecretsStore.prototype.importFromRepo).not.toHaveBeenCalled(); + expect(mocks.supersedeImageBuildsForSecretsChange).not.toHaveBeenCalled(); + expect(mocks.scheduleImageBuildOnSave).not.toHaveBeenCalled(); + } + ); + + it.each([ + { persistedRepoId: 123, resolvedRepoId: 123 }, + { persistedRepoId: null, resolvedRepoId: 456 }, + ])( + "copies freshly resolved ID $resolvedRepoId after grants (persisted ID $persistedRepoId)", + async ({ persistedRepoId, resolvedRepoId }) => { + vi.mocked(EnvironmentStore.prototype.getRepositoriesForEnvironment).mockResolvedValue([ + { ...sourceRepository, repo_id: persistedRepoId }, + ]); + mocks.checkRepositoryAccess.mockResolvedValue({ + repoId: resolvedRepoId, + repoOwner: "acme", + repoName: "repo", + defaultBranch: "main", + }); + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["owner-team", "lead"]]) + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockImplementation( + async (teamId) => { + expect(mocks.checkRepositoryAccess).toHaveBeenCalledOnce(); + return teamId === "owner-team" + ? [{ grant_kind: "repository", repo_external_id: resolvedRepoId }] + : []; + } + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "owner-team", + ]); + + expect((await request(path, "POST", body, environment)).status).toBe(200); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledWith({ owner: "acme", name: "repo" }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).toHaveBeenCalledWith("owner-team"); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).toHaveBeenCalledWith( + resolvedRepoId + ); + expect(EnvironmentSecretsStore.prototype.importFromRepo).toHaveBeenCalledWith( + "env-1", + resolvedRepoId, + undefined + ); + expect(mocks.supersedeImageBuildsForSecretsChange).toHaveBeenCalledOnce(); + expect(mocks.scheduleImageBuildOnSave).toHaveBeenCalledOnce(); + } + ); + } +); + +describe.each(["off", "shadow", "on"] as const)( + "workspace environment secret ownership in %s mode", + (mode) => { + it("preserves workspace-owned import for a custom role without team membership", async () => { + const environment = env(); + environment.TEAMS_ENFORCEMENT = mode; + environment.DB = authorizationDatabase({ + permissions: ["environments.secrets.manage", "environments.manage"], + }); + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue(new Map()); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([]); + vi.mocked(EnvironmentStore.prototype.getById).mockResolvedValue({ + ...destination, + owner_team_id: null, + }); + + expect((await request(path, "POST", body, environment)).status).toBe(200); + // Only admission's memoized membership snapshot. + expect(TeamMembershipStore.prototype.listForUser).toHaveBeenCalledOnce(); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).toHaveBeenCalledWith(123); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledOnce(); + }); + } +); + +describe.each(["owner", "administrator"] as const)( + "built-in %s environment secret authorization", + (key) => { + beforeEach(() => { + vi.spyOn(AuthorizationStore.prototype, "getEffectiveAuthorization").mockResolvedValue({ + userId: "user-1", + suspendedAt: null, + role: { ...BUILT_IN_ROLE_REGISTRY[key], name: key }, + }); + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue(new Map()); + }); + + it("allows environment secret import from another team's source without lead membership", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockImplementation( + async (teamId) => + teamId === "owner-team" ? [{ grant_kind: "repository", repo_external_id: 123 }] : [] + ); + + expect((await request(path, "POST", body)).status).toBe(200); + expect(TeamRepositoryGrantStore.prototype.listForTeam).toHaveBeenCalledWith("owner-team"); + // Only admission's memoized membership snapshot. + expect(TeamMembershipStore.prototype.listForUser).toHaveBeenCalledOnce(); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledOnce(); + expect(EnvironmentSecretsStore.prototype.importFromRepo).toHaveBeenCalledWith( + "env-1", + 123, + undefined + ); + }); + + it("cannot import source secrets without the destination team's covering grant", async () => { + const response = await request(path, "POST", body); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ + error: "Target team lacks repository grant", + code: "target_team_missing_grant", + repository: "acme/repo", + }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).toHaveBeenCalledWith("owner-team"); + expect(EnvironmentSecretsStore.prototype.importFromRepo).not.toHaveBeenCalled(); + expect(mocks.supersedeImageBuildsForSecretsChange).not.toHaveBeenCalled(); + expect(mocks.scheduleImageBuildOnSave).not.toHaveBeenCalled(); + }); + } +); diff --git a/packages/control-plane/src/routes/environment-secrets.ts b/packages/control-plane/src/routes/environment-secrets.ts index 4330bcfca7..ee7a4e9c61 100644 --- a/packages/control-plane/src/routes/environment-secrets.ts +++ b/packages/control-plane/src/routes/environment-secrets.ts @@ -23,13 +23,19 @@ import { json, error, resolveRepoOrError, - requirePermission, + requireAll, + permissionRequirement, + environmentRequirement, } from "./shared"; import { environmentSecretsImportBodySchema, secretsRequestBodySchema, } from "./secret-request-schemas"; import type { Env } from "../types"; +import { + authorizeTeamRepositories, + authorizeWorkspaceRepositories, +} from "./workspace-repository-authorization"; const logger = createLogger("router:environment-secrets"); @@ -256,11 +262,28 @@ async function handleImportEnvironmentSecrets( return error(`${srcOwner}/${srcName} is not a member of this environment`, 403); } - // Resolve the source repo_id (rows written before resolution may lack it). - let repoId = sourceRepo.repo_id; - if (repoId == null) { - repoId = (await resolveRepoOrError(env, srcOwner, srcName, ctx, logger)).repoId; + const { repoId } = await resolveRepoOrError(env, srcOwner, srcName, ctx, logger); + if (sourceRepo.repo_id !== null && sourceRepo.repo_id !== repoId) { + return json( + { + error: "Repository identity changed", + code: "repository_identity_mismatch", + repository: `${srcOwner}/${srcName}`, + }, + 409 + ); } + const denied = await authorizeTeamRepositories(ctx, { + teamId: environment.owner_team_id, + repositories: [{ owner: srcOwner, name: srcName, repoId }], + }); + if (denied) return denied; + + const sourceDenied = await authorizeWorkspaceRepositories(ctx, { + repositories: [{ owner: srcOwner, name: srcName, repoId }], + requireLead: true, + }); + if (sourceDenied) return sourceDenied; const secretsStore = new EnvironmentSecretsStore(ctx.db, config.key); try { @@ -299,13 +322,24 @@ async function handleImportEnvironmentSecrets( const ENVIRONMENT_SECRETS_MANAGE = admit({ ...GITHUB_USER_OR_SERVICE_ROUTE, - authorization: requirePermission("environments.secrets.manage"), + authorization: requireAll( + permissionRequirement("environments.secrets.manage"), + environmentRequirement("manage") + ), }); export const environmentSecretsRoutes = new Hono(); -environmentSecretsRoutes.get("/environments/:id/secrets", ENVIRONMENT_SECRETS_MANAGE, (c) => - dispatch(c, handleListEnvironmentSecrets) +environmentSecretsRoutes.get( + "/environments/:id/secrets", + admit({ + ...GITHUB_USER_OR_SERVICE_ROUTE, + authorization: requireAll( + permissionRequirement("environments.secrets.manage"), + environmentRequirement("read") + ), + }), + (c) => dispatch(c, handleListEnvironmentSecrets) ); environmentSecretsRoutes.put("/environments/:id/secrets", ENVIRONMENT_SECRETS_MANAGE, (c) => dispatch(c, handleSetEnvironmentSecrets) diff --git a/packages/control-plane/src/routes/environments-catalog.test.ts b/packages/control-plane/src/routes/environments-catalog.test.ts new file mode 100644 index 0000000000..e3cf939582 --- /dev/null +++ b/packages/control-plane/src/routes/environments-catalog.test.ts @@ -0,0 +1,344 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BUILT_IN_ROLE_REGISTRY } from "@open-inspect/shared/rbac"; +import type * as AuthenticateModule from "../auth/authenticate"; +import * as requestAudit from "../authorization/request-audit"; +import { AuthorizationStore } from "../db/authorization-store"; +import { + EnvironmentStore, + toEnvironment, + type EnvironmentRepositoryRow, + type EnvironmentRow, +} from "../db/environments"; +import { TeamMembershipStore } from "../db/team-memberships"; +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import { TeamStore } from "../db/teams"; +import { + authorizationDatabase, + createTestEnv, + createTestRequestHandler, + TEST_BACKGROUND_TASK_CONTEXT, +} from "../router.test-support"; +import * as sourceControl from "../source-control"; +import type { Env } from "../types"; +import { environmentRoutes } from "./environments"; + +const mocks = vi.hoisted(() => ({ authenticate: vi.fn() })); +vi.mock("../auth/authenticate", async (importOriginal) => ({ + ...(await importOriginal()), + authenticate: mocks.authenticate, +})); + +const TEAM_ID = "team_selected"; +const targets: [string, (number | null)[]][] = [ + ["covered", [1]], + ["denied", [2]], + ["multi", [1, 2]], + ["nullable", [null]], + ["empty", []], +]; +const catalog: EnvironmentRow[] = targets.map(([name]) => ({ + id: `env_${name}`, + owner_team_id: null, + name, + description: null, + prebuild_enabled: 0, + channel_associations: null, + created_at: 1, + updated_at: 1, +})); +const repositoriesById = new Map( + targets.map(([name, repoIds]) => [ + `env_${name}`, + repoIds.map((repoId, position) => ({ + environment_id: `env_${name}`, + position, + repo_owner: "acme", + repo_name: `repo-${repoId ?? 1}`, + repo_id: repoId, + base_branch: "main", + })), + ]) +); +const READER = { canRead: true, canManage: false, canUse: false }; +const ADMIN_CAPABILITIES = { canRead: true, canManage: true, canUse: true }; +function listed(rows: EnvironmentRow[], capabilities = READER) { + return rows.map((row) => ({ + ...toEnvironment(row, repositoriesById.get(row.id) ?? []), + capabilities, + })); +} +const fullCatalog = listed(catalog); +const handleRequest = createTestRequestHandler([environmentRoutes]); +let environment: Env; + +function list(query = "?teamId=team_selected"): Promise { + return handleRequest( + new Request(`https://test.local/environments${query}`), + environment, + TEST_BACKGROUND_TASK_CONTEXT + ); +} + +describe("environment catalog team scope", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.authenticate.mockImplementation(async (request: Request) => ({ + principal: { kind: "user", userId: "user-1" }, + request, + })); + environment = createTestEnv({ + DB: authorizationDatabase({ permissions: ["environments.read"] }), + }); + vi.spyOn(TeamStore.prototype, "isActive").mockResolvedValue(true); + vi.spyOn(TeamMembershipStore.prototype, "listForUser").mockResolvedValue( + new Map([[TEAM_ID, "member"]]) + ); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 1 }, + ]); + vi.spyOn(TeamRepositoryGrantStore.prototype, "covers"); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listTeamsForRepository"); + vi.spyOn(EnvironmentStore.prototype, "list").mockResolvedValue({ + environments: catalog, + total: catalog.length, + }); + vi.spyOn(EnvironmentStore.prototype, "getRepositoriesForEnvironmentIds").mockResolvedValue( + repositoriesById + ); + vi.spyOn(EnvironmentStore.prototype, "getRepositoriesForEnvironment"); + vi.spyOn(requestAudit, "auditRouteAuthorizationDecision").mockResolvedValue(undefined); + vi.spyOn(sourceControl, "createSourceControlProviderFromEnv").mockImplementation(() => { + throw new Error("Catalog listing must not resolve SCM repositories"); + }); + }); + afterEach(() => { + expect(sourceControl.createSourceControlProviderFromEnv).not.toHaveBeenCalled(); + expect(EnvironmentStore.prototype.getRepositoriesForEnvironment).not.toHaveBeenCalled(); + expect(TeamRepositoryGrantStore.prototype.covers).not.toHaveBeenCalled(); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + }); + + it.each([null, TEAM_ID])( + "returns only fully covered targets for environment owner %s", + async (ownerTeamId) => { + const owned = catalog.map((row) => ({ ...row, owner_team_id: ownerTeamId })); + vi.mocked(EnvironmentStore.prototype.list).mockResolvedValue({ + environments: owned, + total: catalog.length, + }); + + const response = await list(); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ environments: listed(owned.slice(0, 1)), total: 1 }); + expect(TeamMembershipStore.prototype.listForUser).toHaveBeenCalledWith("user-1"); + expect(TeamRepositoryGrantStore.prototype.listForTeam).toHaveBeenCalledExactlyOnceWith( + TEAM_ID + ); + expect( + EnvironmentStore.prototype.getRepositoriesForEnvironmentIds + ).toHaveBeenCalledExactlyOnceWith(catalog.map((row) => row.id)); + expect(requestAudit.auditRouteAuthorizationDecision).not.toHaveBeenCalled(); + } + ); + + it("excludes covered targets another team owns, even when the viewer can read them", async () => { + // Sessions for one team reject another team's environment, so the catalog must too. + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([ + [TEAM_ID, "member"], + ["team_other", "member"], + ]) + ); + vi.mocked(EnvironmentStore.prototype.list).mockResolvedValue({ + environments: catalog.map((row) => ({ ...row, owner_team_id: "team_other" })), + total: catalog.length, + }); + + const response = await list(); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ environments: [], total: 0 }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).toHaveBeenCalledExactlyOnceWith(TEAM_ID); + expect(requestAudit.auditRouteAuthorizationDecision).not.toHaveBeenCalled(); + }); + + it("includes a multi-repository target only when every numeric ID is granted", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 1 }, + { grant_kind: "repository", repo_external_id: 2 }, + ]); + + expect(await (await list()).json()).toEqual({ + environments: fullCatalog.slice(0, 3), + total: 3, + }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).toHaveBeenCalledOnce(); + }); + + it("returns all nonempty targets, including nullable IDs, for an installation grant", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "installation", repo_external_id: null }, + ]); + + expect(await (await list()).json()).toEqual({ + environments: fullCatalog.slice(0, 4), + total: 4, + }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).toHaveBeenCalledOnce(); + }); + + it("returns an empty catalog when the team has no grants", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([]); + + expect(await (await list()).json()).toEqual({ environments: [], total: 0 }); + }); + + it.each(["", "?teamId="])("preserves the full workspace catalog for %s", async (query) => { + expect(await (await list(query)).json()).toEqual({ + environments: fullCatalog, + total: catalog.length, + }); + expect(TeamStore.prototype.isActive).not.toHaveBeenCalled(); + // The only membership lookup is the viewer snapshot for read filtering, not a team gate. + expect(TeamMembershipStore.prototype.listForUser).toHaveBeenCalledOnce(); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + }); + + it.each([TEAM_ID, "team_missing"])( + "conceals and audits nonmember or missing team %s identically", + async (teamId) => { + vi.mocked(TeamStore.prototype.isActive).mockResolvedValue(teamId === TEAM_ID); + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["team_other", "lead"]]) + ); + + const response = await list(`?teamId=${teamId}`); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "Team not found" }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + expect(EnvironmentStore.prototype.list).not.toHaveBeenCalled(); + expect(requestAudit.auditRouteAuthorizationDecision).toHaveBeenCalledExactlyOnceWith({ + ctx: expect.anything(), + method: "GET", + path: "/environments", + response, + teamId, + decision: { + kind: "denied", + reasonCode: "team_not_visible", + reason: "Team not found", + requirements: [{ kind: "team", teamIdParam: "teamId", need: "member" }], + effectivePermissions: [], + }, + }); + } + ); + + it.each(["owner", "administrator"] as const)( + "allows built-in %s without membership but still applies the team's grants", + async (key) => { + vi.spyOn(AuthorizationStore.prototype, "getEffectiveAuthorization").mockResolvedValue({ + userId: "user-1", + suspendedAt: null, + role: { ...BUILT_IN_ROLE_REGISTRY[key], name: key }, + }); + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue(new Map()); + + expect(await (await list()).json()).toEqual({ + environments: listed(catalog.slice(0, 1), ADMIN_CAPABILITIES), + total: 1, + }); + // The bypass skips the team membership gate; the one lookup is the read-filter viewer. + expect(TeamMembershipStore.prototype.listForUser).toHaveBeenCalledOnce(); + + vi.mocked(TeamStore.prototype.isActive).mockResolvedValue(false); + const archivedResponse = await list(); + expect(archivedResponse.status).toBe(404); + expect(await archivedResponse.json()).toEqual({ error: "Team not found" }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).toHaveBeenCalledOnce(); + } + ); + + it("conceals an archived team even from its members", async () => { + vi.mocked(TeamStore.prototype.isActive).mockResolvedValue(false); + + const response = await list(); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "Team not found" }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + expect(EnvironmentStore.prototype.list).not.toHaveBeenCalled(); + }); + + it.each(["", "?teamId=team_selected"])( + "retains environments.read admission for %s", + async (query) => { + environment.DB = authorizationDatabase({ permissions: ["repositories.use"] }); + + const response = await list(query); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: "Forbidden", + code: "permission_required", + permission: "environments.read", + }); + expect(TeamStore.prototype.isActive).not.toHaveBeenCalled(); + expect(EnvironmentStore.prototype.list).not.toHaveBeenCalled(); + } + ); + + it.each(["slack-bot", "linear-bot"] as const)( + "preserves actorless %s workspace access but conceals an opaque team scope", + async (service) => { + mocks.authenticate.mockImplementation(async (request: Request) => ({ + principal: { kind: "service", service, actor: null }, + request, + })); + + expect(await (await list("")).json()).toEqual({ + environments: listed(catalog, { canRead: true, canManage: false, canUse: true }), + total: catalog.length, + }); + vi.mocked(EnvironmentStore.prototype.list).mockClear(); + const response = await list(); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "Team not found" }); + expect(TeamMembershipStore.prototype.listForUser).not.toHaveBeenCalled(); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + expect(EnvironmentStore.prototype.list).not.toHaveBeenCalled(); + expect(requestAudit.auditRouteAuthorizationDecision).toHaveBeenCalledWith( + expect.objectContaining({ + path: "/environments", + teamId: TEAM_ID, + decision: expect.objectContaining({ reasonCode: "team_not_visible" }), + }) + ); + } + ); + + it("scopes an acting service using its canonical actor's actual membership", async () => { + mocks.authenticate.mockImplementation(async (request: Request) => ({ + principal: { + kind: "service", + service: "slack-bot", + actor: { + provider: "slack", + providerUserId: "U_ACTOR", + participantUserId: "slack:U_ACTOR", + canonicalUserId: "user-1", + }, + }, + request, + })); + + expect(await (await list()).json()).toEqual({ environments: [fullCatalog[0]], total: 1 }); + // Both the team gate and the read-filter viewer resolve the canonical actor's memberships. + const lookups = vi.mocked(TeamMembershipStore.prototype.listForUser).mock.calls; + expect(lookups.length).toBeGreaterThan(0); + expect(lookups.every(([userId]) => userId === "user-1")).toBe(true); + }); +}); diff --git a/packages/control-plane/src/routes/environments-target-denied.test.ts b/packages/control-plane/src/routes/environments-target-denied.test.ts new file mode 100644 index 0000000000..3ad17d6292 --- /dev/null +++ b/packages/control-plane/src/routes/environments-target-denied.test.ts @@ -0,0 +1,323 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { PERMISSION_IDS, type PermissionId } from "@open-inspect/shared/rbac"; +import type * as AuthenticateModule from "../auth/authenticate"; +import type * as SourceControlModule from "../source-control"; +import { + EnvironmentStore, + type EnvironmentRepositoryRow, + type EnvironmentRow, +} from "../db/environments"; +import { TeamMembershipStore } from "../db/team-memberships"; +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import { TeamStore } from "../db/teams"; +import { scheduleImageBuildOnSave } from "../image-builds/save-hooks"; +import { + authorizationDatabase, + createTestEnv, + createTestRequestHandler, + TEST_BACKGROUND_TASK_CONTEXT, +} from "../router.test-support"; +import { environmentRoutes } from "./environments"; + +const scmProvider = vi.hoisted(() => ({ checkRepositoryAccess: vi.fn() })); + +vi.mock("../auth/authenticate", async (importOriginal) => ({ + ...(await importOriginal()), + authenticate: async (request: Request) => ({ + principal: { kind: "user", userId: "user-1" }, + request, + }), +})); + +vi.mock("../source-control", async (importOriginal) => ({ + ...(await importOriginal()), + createSourceControlProviderFromEnv: vi.fn(() => scmProvider), +})); + +vi.mock("../image-builds/save-hooks", () => ({ scheduleImageBuildOnSave: vi.fn() })); + +const handleRequest = createTestRequestHandler([environmentRoutes]); +const existing: EnvironmentRow = { + id: "env_1", + owner_team_id: "team_alpha", + name: "Alpha", + description: null, + prebuild_enabled: 1, + channel_associations: null, + created_at: 1, + updated_at: 1, +}; +const repositories = [{ repoOwner: "legacy/group", repoName: "old-app" }]; +const existingRepositories: EnvironmentRepositoryRow[] = [ + { + environment_id: "env_1", + position: 0, + repo_owner: "legacy/group", + repo_name: "old-app", + repo_id: 6, + base_branch: "release", + }, +]; +const batch = vi.fn(); + +async function callRoute( + method: "POST" | "PUT", + permissions: readonly PermissionId[] = PERMISSION_IDS, + body: object = { name: "Alpha", repositories, prebuildEnabled: true } +) { + return handleRequest( + new Request(`https://test.local/environments${method === "PUT" ? "/env_1" : ""}`, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + createTestEnv({ DB: authorizationDatabase({ permissions, batch }) }), + TEST_BACKGROUND_TASK_CONTEXT + ); +} + +describe("environment target denials", () => { + beforeEach(() => { + vi.clearAllMocks(); + batch.mockResolvedValue([]); + vi.spyOn(TeamStore.prototype, "isActive").mockResolvedValue(true); + // Team-owned environment management admits only owner-team leads. + vi.spyOn(TeamMembershipStore.prototype, "listForUser").mockResolvedValue( + new Map([["team_alpha", "lead"]]) + ); + vi.spyOn(EnvironmentStore.prototype, "getByName").mockResolvedValue(null); + vi.spyOn(EnvironmentStore.prototype, "getById").mockResolvedValue(existing); + vi.spyOn(EnvironmentStore.prototype, "getRepositoriesForEnvironment").mockResolvedValue( + existingRepositories + ); + scmProvider.checkRepositoryAccess.mockResolvedValue({ + repoId: 7, + repoOwner: "canonical/group", + repoName: "app", + defaultBranch: "main", + }); + }); + afterEach(() => vi.restoreAllMocks()); + + it.each(["POST", "PUT"] as const)( + "denies %s repository selection before SCM lookup or writes", + async (method) => { + const create = vi.spyOn(EnvironmentStore.prototype, "create"); + const update = vi.spyOn(EnvironmentStore.prototype, "update"); + const grants = vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam"); + + const response = await callRoute( + method, + PERMISSION_IDS.filter((permission) => permission !== "repositories.use") + ); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + error: "Forbidden", + code: "permission_required", + permission: "repositories.use", + }); + expect(scmProvider.checkRepositoryAccess).not.toHaveBeenCalled(); + expect(grants).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + expect(batch).not.toHaveBeenCalled(); + expect(scheduleImageBuildOnSave).not.toHaveBeenCalled(); + } + ); + + it("denies updates using the persisted owner team and resolved repository identity", async () => { + const update = vi.spyOn(EnvironmentStore.prototype, "update"); + const covers = vi.spyOn(TeamRepositoryGrantStore.prototype, "covers").mockResolvedValue(false); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([]); + + const response = await callRoute("PUT"); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ + error: "Target team lacks repository grant", + code: "target_team_missing_grant", + repository: "canonical/group/app", + }); + expect(covers).toHaveBeenCalledWith("team_alpha", [7]); + expect(update).not.toHaveBeenCalled(); + expect(batch).not.toHaveBeenCalled(); + expect(scheduleImageBuildOnSave).not.toHaveBeenCalled(); + }); + + it.each([ + { change: "enabling prebuilds", prebuildEnabled: 0, body: { prebuildEnabled: true } }, + { change: "changing metadata", prebuildEnabled: 1, body: { description: "Updated" } }, + ])("denies $change without replacing repositories after grant revocation", async (testCase) => { + vi.mocked(EnvironmentStore.prototype.getById).mockResolvedValue({ + ...existing, + prebuild_enabled: testCase.prebuildEnabled, + }); + const update = vi.spyOn(EnvironmentStore.prototype, "update"); + const covers = vi.spyOn(TeamRepositoryGrantStore.prototype, "covers"); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([]); + + const response = await callRoute("PUT", ["environments.manage"], testCase.body); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ + error: "Target team lacks repository grant", + code: "target_team_missing_grant", + repository: "canonical/group/app", + }); + expect(scmProvider.checkRepositoryAccess).toHaveBeenCalledOnce(); + expect(scmProvider.checkRepositoryAccess).toHaveBeenCalledWith({ + owner: "legacy/group", + name: "old-app", + }); + expect(covers).toHaveBeenCalledWith("team_alpha", [7]); + expect(update).not.toHaveBeenCalled(); + expect(batch).not.toHaveBeenCalled(); + expect(scheduleImageBuildOnSave).not.toHaveBeenCalled(); + }); + + it("checks every unchanged member when a later repository's grant is revoked", async () => { + vi.mocked(EnvironmentStore.prototype.getRepositoriesForEnvironment).mockResolvedValue([ + ...existingRepositories, + { + environment_id: "env_1", + position: 1, + repo_owner: "legacy/group", + repo_name: "old-api", + repo_id: 8, + base_branch: "main", + }, + ]); + scmProvider.checkRepositoryAccess.mockResolvedValueOnce({ + repoId: 7, + repoOwner: "canonical/group", + repoName: "app", + defaultBranch: "main", + }); + scmProvider.checkRepositoryAccess.mockResolvedValueOnce({ + repoId: 8, + repoOwner: "canonical/group", + repoName: "api", + defaultBranch: "main", + }); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 7 }, + ]); + const covers = vi.spyOn(TeamRepositoryGrantStore.prototype, "covers"); + const update = vi.spyOn(EnvironmentStore.prototype, "update"); + + const response = await callRoute("PUT", ["environments.manage"], { description: "Updated" }); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ + error: "Target team lacks repository grant", + code: "target_team_missing_grant", + repository: "canonical/group/api", + }); + expect(scmProvider.checkRepositoryAccess).toHaveBeenCalledTimes(2); + expect(covers).toHaveBeenCalledWith("team_alpha", [7, 8]); + expect(update).not.toHaveBeenCalled(); + expect(batch).not.toHaveBeenCalled(); + expect(scheduleImageBuildOnSave).not.toHaveBeenCalled(); + }); + + it("does not authorize a recreated unchanged repository using its stale persisted ID", async () => { + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 6 }, + ]); + const covers = vi.spyOn(TeamRepositoryGrantStore.prototype, "covers"); + const update = vi.spyOn(EnvironmentStore.prototype, "update"); + + const response = await callRoute("PUT", ["environments.manage"], { description: "Updated" }); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ + error: "Target team lacks repository grant", + code: "target_team_missing_grant", + repository: "canonical/group/app", + }); + expect(covers).toHaveBeenCalledWith("team_alpha", [7]); + expect(update).not.toHaveBeenCalled(); + expect(batch).not.toHaveBeenCalled(); + expect(scheduleImageBuildOnSave).not.toHaveBeenCalled(); + }); + + it("allows granted unchanged members without repositories.use or replacing them", async () => { + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 7 }, + ]); + const covers = vi.spyOn(TeamRepositoryGrantStore.prototype, "covers"); + const update = vi.spyOn(EnvironmentStore.prototype, "update"); + + const response = await callRoute("PUT", ["environments.manage"], { description: "Updated" }); + + expect(response.status).toBe(200); + expect(covers).toHaveBeenCalledWith("team_alpha", [7]); + expect(scmProvider.checkRepositoryAccess).toHaveBeenCalledOnce(); + expect(update).toHaveBeenCalledWith("env_1", { description: "Updated" }, undefined); + expect(batch).toHaveBeenCalledOnce(); + expect(scheduleImageBuildOnSave).toHaveBeenCalledOnce(); + }); + + it("allows disabling prebuilds after revocation without checking unchanged members", async () => { + vi.mocked(EnvironmentStore.prototype.getById) + .mockResolvedValueOnce(existing) + .mockResolvedValue({ ...existing, prebuild_enabled: 0 }); + const grants = vi + .spyOn(TeamRepositoryGrantStore.prototype, "listForTeam") + .mockResolvedValue([]); + const update = vi.spyOn(EnvironmentStore.prototype, "update"); + + const response = await callRoute("PUT", ["environments.manage"], { prebuildEnabled: false }); + + expect(response.status).toBe(200); + expect(update).toHaveBeenCalledWith("env_1", { prebuild_enabled: 0 }, undefined); + expect(batch).toHaveBeenCalledOnce(); + expect(scmProvider.checkRepositoryAccess).not.toHaveBeenCalled(); + expect(grants).not.toHaveBeenCalled(); + expect(scheduleImageBuildOnSave).not.toHaveBeenCalled(); + }); + + it.each([ + { change: "enabling prebuilds", prebuildEnabled: 0, body: { prebuildEnabled: true } }, + { change: "changing metadata", prebuildEnabled: 1, body: { description: "Updated" } }, + ])("preserves unowned workspace behavior when $change", async (testCase) => { + vi.mocked(EnvironmentStore.prototype.getById) + .mockResolvedValueOnce({ + ...existing, + owner_team_id: null, + prebuild_enabled: testCase.prebuildEnabled, + }) + .mockResolvedValue({ ...existing, owner_team_id: null }); + const grants = vi + .spyOn(TeamRepositoryGrantStore.prototype, "listForTeam") + .mockResolvedValue([]); + const update = vi.spyOn(EnvironmentStore.prototype, "update"); + + const response = await callRoute("PUT", ["environments.manage"], testCase.body); + + expect(response.status).toBe(200); + expect(update).toHaveBeenCalledOnce(); + expect(update.mock.calls[0]?.[2]).toBeUndefined(); + expect(batch).toHaveBeenCalledOnce(); + expect(scmProvider.checkRepositoryAccess).not.toHaveBeenCalled(); + expect(grants).not.toHaveBeenCalled(); + expect(scheduleImageBuildOnSave).toHaveBeenCalledOnce(); + }); + + it("does not re-resolve or re-authorize replacement repositories before scheduling", async () => { + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 7 }, + ]); + const covers = vi.spyOn(TeamRepositoryGrantStore.prototype, "covers"); + + const response = await callRoute("PUT"); + + expect(response.status).toBe(200); + expect(scmProvider.checkRepositoryAccess).toHaveBeenCalledOnce(); + expect(covers).toHaveBeenCalledOnce(); + expect(covers).toHaveBeenCalledWith("team_alpha", [7]); + expect(batch).toHaveBeenCalledOnce(); + expect(scheduleImageBuildOnSave).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/control-plane/src/routes/environments.ts b/packages/control-plane/src/routes/environments.ts index 1a7726e982..260b0bf9e9 100644 --- a/packages/control-plane/src/routes/environments.ts +++ b/packages/control-plane/src/routes/environments.ts @@ -8,6 +8,18 @@ import { parseBody } from "./body"; import { Hono } from "hono"; +import { z } from "zod"; +import { + checkEnvironmentAccess, + environmentCapabilities, + teamIdSchema, + type SessionViewer, +} from "@open-inspect/shared"; +import { + admittedEnvironment, + environmentActionDeniedBody, +} from "../authorization/owned-resource-admission"; +import { resourceViewer } from "../authorization/resource-viewer"; import { admit, dispatch } from "../routing/admit"; import type { ControlPlaneHonoEnv } from "../routing/hono-env"; import { @@ -19,23 +31,65 @@ import { toEnvironment, type EnvironmentRow, type EnvironmentRepositoryInsert, + type EnvironmentRepositoryRow, type EnvironmentScalarFields, } from "../db/environments"; import { generateId } from "../auth/crypto"; +import { isUniqueConstraintError } from "../db/errors"; import { scheduleImageBuildOnSave } from "../image-builds/save-hooks"; import { createLogger } from "../logger"; import { resolveSessionRepositories } from "../repos/resolve"; +import { parseQuery } from "./query"; import { GITHUB_USER_OR_SERVICE_ROUTE, type RequestContext, json, error, requirePermission, + requireEnvironment, } from "./shared"; import type { Env } from "../types"; +import { authorizeSessionTarget } from "./session-target-authorization"; +import { + admitTeamCatalog, + resolveCreationOwnerTeam, + type TeamRepositoryGrants, +} from "./team-ownership"; +import { authorizeTeamRepositories } from "./workspace-repository-authorization"; const logger = createLogger("router:environments"); +const listQuerySchema = z.object({ + /** A team's session catalog: environments that team's sessions may launch with. */ + teamId: z.string().optional(), + /** Exact ownership; "null" selects workspace-owned environments. */ + ownerTeamId: z.union([z.literal("null"), teamIdSchema]).optional(), +}); + +function duplicateName(name: string): Response { + return error(`An environment named "${name}" already exists`, 409); +} + +/** Response shape for one environment, with the viewer's capabilities on it. */ +function environmentView( + row: EnvironmentRow, + repositories: EnvironmentRepositoryRow[], + viewer: SessionViewer +) { + return { + ...toEnvironment(row, repositories), + capabilities: environmentCapabilities(viewer, { ownerTeamId: row.owner_team_id }), + }; +} + +function targetRepositories(inserts: EnvironmentRepositoryInsert[]) { + return inserts.map((repository) => ({ + owner: repository.repo_owner, + name: repository.repo_name, + repoId: repository.repo_id, + })); +} + /** Empty/whitespace description collapses to null (the column is nullable). */ function normalizeDescription(description: string | null | undefined): string | null { return description && description.length > 0 ? description : null; @@ -71,21 +125,110 @@ export async function resolveEnvironmentRepositories( })); } +/** + * Preflight the caller's repository permission before resolving IDs (resolution calls the + * source-control provider), then check the owner team's grants against the resolved set. + */ +async function resolveAuthorizedRepositories( + env: Env, + ctx: RequestContext, + ownerTeamId: string | null, + repositories: { repoOwner: string; repoName: string; baseBranch: string | null }[] +): Promise { + const preflightError = await authorizeSessionTarget(ctx, { + teamId: null, + repositories: repositories.map((repository) => ({ + owner: repository.repoOwner, + name: repository.repoName, + })), + }); + if (preflightError) return preflightError; + const inserts = await resolveEnvironmentRepositories(env, repositories, ctx); + const grantError = await authorizeSessionTarget(ctx, { + teamId: ownerTeamId, + repositories: targetRepositories(inserts), + }); + return grantError ?? inserts; +} + +/** + * Prebuilds launch a team environment's repositories for the team, so keeping them enabled + * re-checks the stored set against the team's current grants. Disabling them does not. + */ +async function authorizeStoredTeamRepositories( + env: Env, + ctx: RequestContext, + environmentId: string, + ownerTeamId: string +): Promise { + const stored = await new EnvironmentStore(ctx.db).getRepositoriesForEnvironment(environmentId); + const resolved = await resolveEnvironmentRepositories( + env, + stored.map((repository) => ({ + repoOwner: repository.repo_owner, + repoName: repository.repo_name, + baseBranch: repository.base_branch, + })), + ctx + ); + return authorizeTeamRepositories(ctx, { + teamId: ownerTeamId, + repositories: targetRepositories(resolved), + }); +} + +/** Whether every repository of an environment is covered by a team's grants. */ +function grantsCover( + grants: TeamRepositoryGrants, + repositories: EnvironmentRepositoryRow[] +): boolean { + if (repositories.length === 0) return false; + if (grants.some((grant) => grant.grant_kind === "installation")) return true; + const grantedRepoIds = new Set(grants.map((grant) => grant.repo_external_id)); + return repositories.every( + (repository) => repository.repo_id !== null && grantedRepoIds.has(repository.repo_id) + ); +} + async function handleListEnvironments( - _request: Request, + request: Request, env: Env, _params: object, ctx: RequestContext ): Promise { + const query = parseQuery(request, listQuerySchema); + if (query instanceof Response) return query; + const catalogTeamId = query.teamId || null; + const catalogGrants = catalogTeamId + ? await admitTeamCatalog(request, ctx, catalogTeamId, "/environments") + : null; + if (catalogGrants instanceof Response) return catalogGrants; + const store = new EnvironmentStore(ctx.db); - const { environments, total } = await store.list(); + const viewer = await resourceViewer(ctx); + const { environments: rows } = await store.list( + query.ownerTeamId === "null" ? null : query.ownerTeamId + ); + const readable = (row: EnvironmentRow) => + checkEnvironmentAccess(viewer, { ownerTeamId: row.owner_team_id }, "read").allowed; + // A team's sessions cannot launch with environments other teams own. + const launchableByCatalogTeam = (row: EnvironmentRow) => + !catalogTeamId || row.owner_team_id === null || row.owner_team_id === catalogTeamId; + let environments = rows.filter((row) => readable(row) && launchableByCatalogTeam(row)); const repositoriesById = await store.getRepositoriesForEnvironmentIds( - environments.map((e) => e.id) + environments.map((row) => row.id) ); + if (catalogGrants) { + environments = environments.filter((row) => + grantsCover(catalogGrants, repositoriesById.get(row.id) ?? []) + ); + } return json({ - environments: environments.map((row) => toEnvironment(row, repositoriesById.get(row.id) ?? [])), - total, + environments: environments.map((row) => + environmentView(row, repositoriesById.get(row.id) ?? [], viewer) + ), + total: environments.length, }); } @@ -98,19 +241,24 @@ async function handleCreateEnvironment( const parsed = await parseBody(request, createEnvironmentInputSchema); if (parsed instanceof Response) return parsed; const { name, description, prebuildEnabled, channelAssociations, repositories } = parsed; + const ownerTeamId = parsed.teamId ?? null; + const ownerTeam = await resolveCreationOwnerTeam(ctx, ownerTeamId); + if (ownerTeam instanceof Response) return ownerTeam; + const viewer = await resourceViewer(ctx); + const access = checkEnvironmentAccess(viewer, { ownerTeamId }, "manage"); + if (!access.allowed) return json(environmentActionDeniedBody(access.reason), 403); const store = new EnvironmentStore(ctx.db); - if (await store.getByName(name)) { - return error(`An environment named "${name}" already exists`, 409); - } + if (await store.getByName(name, ownerTeamId)) return duplicateName(name); - const inserts = await resolveEnvironmentRepositories(env, repositories, ctx); + const inserts = await resolveAuthorizedRepositories(env, ctx, ownerTeamId, repositories); + if (inserts instanceof Response) return inserts; const now = Date.now(); const id = `env_${generateId()}`; const row: EnvironmentRow = { id, - owner_team_id: null, + owner_team_id: ownerTeamId, name, description: normalizeDescription(description), prebuild_enabled: prebuildEnabled ? 1 : 0, @@ -119,7 +267,13 @@ async function handleCreateEnvironment( updated_at: now, }; - await store.create(row, inserts); + try { + await store.create(row, inserts); + } catch (cause) { + // Lost a race with a concurrent create of the same name. + if (isUniqueConstraintError(cause)) return duplicateName(name); + throw cause; + } logger.info("environment.created", { event: "environment.created", @@ -135,7 +289,7 @@ async function handleCreateEnvironment( } return json( - { environment: toEnvironment(row, await store.getRepositoriesForEnvironment(id)) }, + { environment: environmentView(row, await store.getRepositoriesForEnvironment(id), viewer) }, 201 ); } @@ -146,13 +300,9 @@ async function handleGetEnvironment( params: { id: string }, ctx: RequestContext ): Promise { - const id = params.id; - - const store = new EnvironmentStore(ctx.db); - const row = await store.getById(id); - if (!row) return error("Environment not found", 404); - - return json({ environment: toEnvironment(row, await store.getRepositoriesForEnvironment(id)) }); + const { environment, viewer } = admittedEnvironment(ctx); + const repositories = await new EnvironmentStore(ctx.db).getRepositoriesForEnvironment(params.id); + return json({ environment: environmentView(environment, repositories, viewer) }); } async function handleUpdateEnvironment( @@ -164,24 +314,34 @@ async function handleUpdateEnvironment( const id = params.id; const store = new EnvironmentStore(ctx.db); - const existing = await store.getById(id); - if (!existing) return error("Environment not found", 404); + const { environment: existing, viewer } = admittedEnvironment(ctx); const parsed = await parseBody(request, updateEnvironmentInputSchema); if (parsed instanceof Response) return parsed; const { name, description, prebuildEnabled, channelAssociations, repositories } = parsed; if (name !== undefined) { - const other = await store.getByName(name); - if (other && other.id !== id) { - return error(`An environment named "${name}" already exists`, 409); - } + const other = await store.getByName(name, existing.owner_team_id); + if (other && other.id !== id) return duplicateName(name); } - const inserts = - repositories !== undefined - ? await resolveEnvironmentRepositories(env, repositories, ctx) - : undefined; + let inserts: EnvironmentRepositoryInsert[] | undefined; + if (repositories !== undefined) { + const authorized = await resolveAuthorizedRepositories( + env, + ctx, + existing.owner_team_id, + repositories + ); + if (authorized instanceof Response) return authorized; + inserts = authorized; + } + + const prebuildsStayEnabled = prebuildEnabled ?? existing.prebuild_enabled === 1; + if (inserts === undefined && prebuildsStayEnabled && existing.owner_team_id !== null) { + const grantError = await authorizeStoredTeamRepositories(env, ctx, id, existing.owner_team_id); + if (grantError) return grantError; + } const fields: EnvironmentScalarFields = {}; if (name !== undefined) fields.name = name; @@ -192,7 +352,14 @@ async function handleUpdateEnvironment( fields.channel_associations = channelAssociationsColumn; } - const updated = await store.update(id, fields, inserts); + let updated: EnvironmentRow | null; + try { + updated = await store.update(id, fields, inserts); + } catch (cause) { + // Lost a race with a concurrent rename to the same name. + if (isUniqueConstraintError(cause)) return duplicateName(name ?? existing.name); + throw cause; + } if (!updated) return error("Environment not found", 404); logger.info("environment.updated", { @@ -208,7 +375,7 @@ async function handleUpdateEnvironment( } return json({ - environment: toEnvironment(updated, await store.getRepositoriesForEnvironment(id)), + environment: environmentView(updated, await store.getRepositoriesForEnvironment(id), viewer), }); } @@ -236,7 +403,7 @@ async function handleDeleteEnvironment( const ENVIRONMENTS_MANAGE = admit({ ...GITHUB_USER_OR_SERVICE_ROUTE, - authorization: requirePermission("environments.manage"), + authorization: requireEnvironment("manage"), }); export const environmentRoutes = new Hono(); @@ -251,14 +418,19 @@ environmentRoutes.get( }), (c) => dispatch(c, handleListEnvironments) ); -environmentRoutes.post("/environments", ENVIRONMENTS_MANAGE, (c) => - dispatch(c, handleCreateEnvironment) +environmentRoutes.post( + "/environments", + admit({ + ...GITHUB_USER_OR_SERVICE_ROUTE, + authorization: requirePermission("environments.manage"), + }), + (c) => dispatch(c, handleCreateEnvironment) ); environmentRoutes.get( "/environments/:id", admit({ ...GITHUB_USER_OR_SERVICE_ROUTE, - authorization: requirePermission("environments.read", { + authorization: requireEnvironment("read", "id", { actorlessGrants: [{ service: "github-bot" }], }), }), diff --git a/packages/control-plane/src/routes/github-reviewer-token.ts b/packages/control-plane/src/routes/github-reviewer-token.ts index b42fe551b1..15842dc925 100644 --- a/packages/control-plane/src/routes/github-reviewer-token.ts +++ b/packages/control-plane/src/routes/github-reviewer-token.ts @@ -17,8 +17,10 @@ import { resolveAppName } from "@open-inspect/shared/app-name"; import { Hono } from "hono"; import { getCachedInstallationToken, getGitHubReviewerAppConfig } from "../auth/github-app"; import { SessionIndexStore } from "../db/session-index"; +import { readCachedInstallationRepositories } from "../repos/cache"; import { createLogger } from "../logger"; import { admit, dispatch } from "../routing/admit"; +import { resolveRepositoryCredentialScope } from "../source-control/repository-scope"; import type { ControlPlaneHonoEnv } from "../routing/hono-env"; import type { Env } from "../types"; import { @@ -60,9 +62,9 @@ async function handleReviewerToken( // comments and never submit a review there, so only a registered review session (one with a // review fence row) gets the reviewer credential. Goes upstream once #1370 and #1862 merge. const reviewFence = await ctx.db - .prepare("SELECT 1 FROM github_review_sessions WHERE session_id = ? LIMIT 1") + .prepare("SELECT repo_id FROM github_review_sessions WHERE session_id = ? LIMIT 1") .bind(params.id) - .first(); + .first<{ repo_id: number }>(); if (!reviewFence) { logger.warn("review_token.session_not_eligible", { event: "review_token.session_not_eligible", @@ -76,10 +78,26 @@ async function handleReviewerToken( } try { - const token = await getCachedInstallationToken(reviewerAppConfig, { - cacheStore: env.REPOS_CACHE, - userAgent: resolveAppName(env), - }); + // The review POST targets the reviewed repository alone, so the token covers only that one, + // still intersected with the session owner team's current grants. Requesting the session's + // other repositories would fail wherever the reviewer App is not installed on them. + const scope = await resolveRepositoryCredentialScope( + ctx.db, + [ + { + repoOwner: session.repoOwner ?? "", + repoName: session.repoName ?? "", + repoId: reviewFence.repo_id, + }, + ], + session.ownerTeamId, + () => readCachedInstallationRepositories(env) + ); + const token = await getCachedInstallationToken( + reviewerAppConfig, + { cacheStore: env.REPOS_CACHE, userAgent: resolveAppName(env) }, + { scope } + ); return json({ token }); } catch (cause) { logger.error("review_token.mint_failed", { diff --git a/packages/control-plane/src/routes/image-builds.repository-grants.test.ts b/packages/control-plane/src/routes/image-builds.repository-grants.test.ts new file mode 100644 index 0000000000..fc3a93d9b2 --- /dev/null +++ b/packages/control-plane/src/routes/image-builds.repository-grants.test.ts @@ -0,0 +1,435 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BUILT_IN_ROLE_REGISTRY } from "@open-inspect/shared/rbac"; +import { + authorizationDatabase, + createRepositoryGrantEnv, + createRepositoryGrantRequest, + setupRepositoryGrantSpies, +} from "./repository-grants.test-support"; +import { AuthorizationStore } from "../db/authorization-store"; +import { EnvironmentStore } from "../db/environments"; +import { RepoMetadataStore } from "../db/repo-metadata"; +import { TeamMembershipStore } from "../db/team-memberships"; +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import type * as SourceControlModule from "../source-control"; +import type { Env } from "../types"; +import { imageBuildRoutes } from "./image-builds"; + +const mocks = vi.hoisted(() => ({ + checkRepositoryAccess: vi.fn(), + triggerBuild: vi.fn(), + scheduleImageBuildOnSave: vi.fn(), +})); +vi.mock("../source-control", async (importOriginal) => ({ + ...(await importOriginal()), + createSourceControlProviderFromEnv: () => ({ + name: "github", + checkRepositoryAccess: mocks.checkRepositoryAccess, + }), +})); +vi.mock("../image-builds/workflow", () => ({ + createImageBuildWorkflowFromEnv: () => ({ + triggerBuild: mocks.triggerBuild, + triggerBuildWithTarget: mocks.triggerBuild, + }), +})); +vi.mock("../image-builds/save-hooks", () => ({ + scheduleImageBuildOnSave: mocks.scheduleImageBuildOnSave, +})); + +// Environment triggers also admit through environments.manage (lead-only on team-owned rows). +const env = () => + createRepositoryGrantEnv([ + "repositories.images.manage", + "environments.images.manage", + "environments.manage", + ]); +const request = createRepositoryGrantRequest(imageBuildRoutes, env); +const repositoryImageWrites = [ + ["POST", "/image-builds/trigger/repo/acme/repo", undefined], + ["PUT", "/image-builds/toggle/repo/acme/repo", { enabled: true }], +] as const; +const environmentRow = { + id: "env-1", + owner_team_id: "owner-team", + name: "Environment", + description: null, + prebuild_enabled: 0, + channel_associations: null, + created_at: 1, + updated_at: 1, +}; +const environmentRepository = { + environment_id: "env-1", + position: 0, + repo_owner: "acme", + repo_name: "repo", + repo_id: 123, + base_branch: "main", +}; + +beforeEach(() => { + vi.clearAllMocks(); + setupRepositoryGrantSpies(); + mocks.checkRepositoryAccess.mockResolvedValue({ + repoId: 123, + repoOwner: "acme", + repoName: "repo", + defaultBranch: "main", + }); + mocks.triggerBuild.mockResolvedValue({ type: "building", buildId: "build-1" }); + vi.spyOn(EnvironmentStore.prototype, "getById").mockResolvedValue(environmentRow); + vi.spyOn(EnvironmentStore.prototype, "getRepositoriesForEnvironment").mockResolvedValue([ + environmentRepository, + ]); + vi.spyOn(RepoMetadataStore.prototype, "setImageBuildEnabled").mockResolvedValue(undefined); +}); +afterEach(() => vi.restoreAllMocks()); + +describe("image build repository-bearing writes", () => { + it("denies a repo trigger before invoking the workflow", async () => { + expect((await request("/image-builds/trigger/repo/acme/repo", "POST")).status).toBe(403); + expect(mocks.triggerBuild).not.toHaveBeenCalled(); + }); + it("denies toggle-on before writing or scheduling", async () => { + expect( + (await request("/image-builds/toggle/repo/acme/repo", "PUT", { enabled: true })).status + ).toBe(403); + expect(RepoMetadataStore.prototype.setImageBuildEnabled).not.toHaveBeenCalled(); + expect(mocks.scheduleImageBuildOnSave).not.toHaveBeenCalled(); + }); + it("denies an environment trigger when its persisted owner lacks grants", async () => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["owner-team", "lead"]]) + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockImplementation(async (teamId) => + teamId === "team-1" ? [{ grant_kind: "installation", repo_external_id: null }] : [] + ); + expect((await request("/image-builds/trigger/environment/env-1", "POST")).status).toBe(409); + expect(mocks.triggerBuild).not.toHaveBeenCalled(); + }); + it("allows a granted repo trigger with images.manage alone", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "team-1", + ]); + expect((await request("/image-builds/trigger/repo/acme/repo", "POST")).status).toBe(200); + expect(mocks.triggerBuild).toHaveBeenCalledOnce(); + expect(mocks.triggerBuild).toHaveBeenCalledWith( + { kind: "repo", id: "acme/repo" }, + expect.objectContaining({ kind: "repo", repoId: 123 }), + expect.objectContaining({ request_id: expect.any(String) }) + ); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledOnce(); + }); + it("allows an owning-team member to enable repo images with images.manage alone", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "team-1", + ]); + + expect( + (await request("/image-builds/toggle/repo/acme/repo", "PUT", { enabled: true })).status + ).toBe(200); + expect(RepoMetadataStore.prototype.setImageBuildEnabled).toHaveBeenCalledWith( + "acme", + "repo", + true + ); + expect(mocks.scheduleImageBuildOnSave).toHaveBeenCalledOnce(); + }); + it("allows disabling without SCM resolution or a surviving grant", async () => { + mocks.checkRepositoryAccess.mockRejectedValue(new Error("Repository gone")); + expect( + (await request("/image-builds/toggle/repo/acme/repo", "PUT", { enabled: false })).status + ).toBe(200); + expect(RepoMetadataStore.prototype.setImageBuildEnabled).toHaveBeenCalledWith( + "acme", + "repo", + false + ); + expect(mocks.checkRepositoryAccess).not.toHaveBeenCalled(); + }); + it("preserves workspace-owned environment trigger behavior", async () => { + vi.mocked(EnvironmentStore.prototype.getById).mockResolvedValue({ + ...environmentRow, + owner_team_id: null, + }); + expect((await request("/image-builds/trigger/environment/env-1", "POST")).status).toBe(200); + expect(mocks.triggerBuild).toHaveBeenCalledOnce(); + }); + + it("checks every current repository against the environment owner regardless of persisted IDs", async () => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["owner-team", "lead"]]) + ); + vi.mocked(EnvironmentStore.prototype.getRepositoriesForEnvironment).mockResolvedValue([ + environmentRepository, + { + ...environmentRepository, + position: 1, + repo_name: "other", + repo_id: null, + }, + ]); + mocks.checkRepositoryAccess.mockImplementation( + async ({ owner, name }: { owner: string; name: string }) => ({ + repoId: name === "other" ? 456 : 123, + repoOwner: owner, + repoName: name, + defaultBranch: "main", + }) + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockImplementation(async (teamId) => + teamId === "owner-team" ? [{ grant_kind: "repository", repo_external_id: 123 }] : [] + ); + expect((await request("/image-builds/trigger/environment/env-1", "POST")).status).toBe(409); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledWith({ owner: "acme", name: "repo" }); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledWith({ owner: "acme", name: "other" }); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledTimes(2); + expect(mocks.triggerBuild).not.toHaveBeenCalled(); + }); + + it("rejects a reused environment repository name when only the persisted old ID is granted", async () => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["owner-team", "lead"]]) + ); + mocks.checkRepositoryAccess.mockResolvedValue({ + repoId: 456, + repoOwner: "acme", + repoName: "repo", + defaultBranch: "main", + }); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockImplementation(async (teamId) => + teamId === "owner-team" ? [{ grant_kind: "repository", repo_external_id: 123 }] : [] + ); + + expect((await request("/image-builds/trigger/environment/env-1", "POST")).status).toBe(409); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledWith({ owner: "acme", name: "repo" }); + expect(mocks.triggerBuild).not.toHaveBeenCalled(); + }); + + it("allows the current environment repository ID when the persisted ID is stale", async () => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["owner-team", "lead"]]) + ); + mocks.checkRepositoryAccess.mockResolvedValue({ + repoId: 456, + repoOwner: "acme", + repoName: "repo", + defaultBranch: "main", + }); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockImplementation(async (teamId) => + teamId === "owner-team" ? [{ grant_kind: "repository", repo_external_id: 456 }] : [] + ); + + expect((await request("/image-builds/trigger/environment/env-1", "POST")).status).toBe(200); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledOnce(); + expect(mocks.triggerBuild).toHaveBeenCalledOnce(); + }); + + it("rejects a disappeared environment repository despite its persisted ID and grant", async () => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["owner-team", "lead"]]) + ); + mocks.checkRepositoryAccess.mockResolvedValue(null); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 123 }, + ]); + + expect((await request("/image-builds/trigger/environment/env-1", "POST")).status).toBe(404); + expect(mocks.triggerBuild).not.toHaveBeenCalled(); + }); + + it("allows an environment owner-team lead with only environment permissions", async () => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([ + ["team-1", "member"], + ["owner-team", "lead"], + ]) + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockImplementation(async (teamId) => + teamId === "owner-team" ? [{ grant_kind: "repository", repo_external_id: 123 }] : [] + ); + const environment = env(); + environment.DB = authorizationDatabase({ + permissions: ["environments.images.manage", "environments.manage"], + }); + expect( + (await request("/image-builds/trigger/environment/env-1", "POST", undefined, environment)) + .status + ).toBe(200); + expect(mocks.triggerBuild).toHaveBeenCalledOnce(); + expect(TeamRepositoryGrantStore.prototype.listForTeam).toHaveBeenCalledWith("owner-team"); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalledWith("team-1"); + }); + + it("denies an owner-team member without the lead role before SCM access", async () => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["owner-team", "member"]]) + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "installation", repo_external_id: null }, + ]); + const response = await request("/image-builds/trigger/environment/env-1", "POST"); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + error: "Forbidden", + code: "environment_action_denied", + reason_code: "not_owner_or_lead", + }); + expect(mocks.checkRepositoryAccess).not.toHaveBeenCalled(); + expect(mocks.triggerBuild).not.toHaveBeenCalled(); + }); + + it.each(["off", "shadow", "on"] as const)( + "denies a nonmember before SCM access in %s mode even when the owner team has grants", + async (mode) => { + const environment = env(); + environment.TEAMS_ENFORCEMENT = mode; + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "installation", repo_external_id: null }, + ]); + const response = await request( + "/image-builds/trigger/environment/env-1", + "POST", + undefined, + environment + ); + + // Team-owned environments are invisible to nonmembers. + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ error: "Environment not found" }); + expect(EnvironmentStore.prototype.getRepositoriesForEnvironment).not.toHaveBeenCalled(); + expect(mocks.checkRepositoryAccess).not.toHaveBeenCalled(); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + expect(mocks.triggerBuild).not.toHaveBeenCalled(); + } + ); + + it("denies an environment trigger for a caller without any team membership", async () => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue(new Map()); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 123 }, + ]); + + expect((await request("/image-builds/trigger/environment/env-1", "POST")).status).toBe(404); + expect(mocks.checkRepositoryAccess).not.toHaveBeenCalled(); + expect(mocks.triggerBuild).not.toHaveBeenCalled(); + }); + + it("denies an unrelated team lead even when their own team has an installation grant", async () => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["team-1", "lead"]]) + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "installation", repo_external_id: null }, + ]); + + expect((await request("/image-builds/trigger/environment/env-1", "POST")).status).toBe(404); + expect(mocks.checkRepositoryAccess).not.toHaveBeenCalled(); + expect(mocks.triggerBuild).not.toHaveBeenCalled(); + }); +}); + +describe.each(["off", "shadow", "on"] as const)( + "workspace image build repository ownership in %s mode", + (mode) => { + let environment: Env; + beforeEach(() => { + environment = env(); + environment.TEAMS_ENFORCEMENT = mode; + }); + + it.each(repositoryImageWrites)( + "allows image %s with no grants anywhere and only existing permissions", + async (method, path, body) => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue(new Map()); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([]); + expect((await request(path, method, body, environment)).status).toBe(200); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).toHaveBeenCalledWith(123); + } + ); + + it.each(["member", "lead", null] as const)( + "denies another team's repositories for an unrelated %s across image routes", + async (role) => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + role === null ? new Map() : new Map([["team-1", role]]) + ); + for (const [method, path, body] of repositoryImageWrites) { + expect((await request(path, method, body, environment)).status).toBe(403); + } + expect(RepoMetadataStore.prototype.setImageBuildEnabled).not.toHaveBeenCalled(); + expect(mocks.scheduleImageBuildOnSave).not.toHaveBeenCalled(); + expect(mocks.triggerBuild).not.toHaveBeenCalled(); + } + ); + + it("preserves workspace-owned trigger with only its custom-role environment permissions", async () => { + environment.DB = authorizationDatabase({ + permissions: ["environments.images.manage", "environments.manage"], + }); + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue(new Map()); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([]); + vi.mocked(EnvironmentStore.prototype.getById).mockResolvedValue({ + ...environmentRow, + owner_team_id: null, + }); + + expect( + (await request("/image-builds/trigger/environment/env-1", "POST", undefined, environment)) + .status + ).toBe(200); + // Only admission's membership snapshot; the handler's owner-team check is skipped. + expect(TeamMembershipStore.prototype.listForUser).toHaveBeenCalledOnce(); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).not.toHaveBeenCalled(); + }); + } +); + +describe.each(["owner", "administrator"] as const)( + "built-in %s image build repository authorization", + (key) => { + beforeEach(() => { + vi.spyOn(AuthorizationStore.prototype, "getEffectiveAuthorization").mockResolvedValue({ + userId: "user-1", + suspendedAt: null, + role: { ...BUILT_IN_ROLE_REGISTRY[key], name: key }, + }); + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue(new Map()); + }); + + it.each(repositoryImageWrites)( + "allows image %s for another team's repository without membership", + async (method, path, body) => { + expect((await request(path, method, body)).status).toBe(200); + } + ); + + it("allows a team-owned environment trigger when the owner team has a covering grant", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockImplementation( + async (teamId) => + teamId === "owner-team" ? [{ grant_kind: "repository", repo_external_id: 123 }] : [] + ); + + expect((await request("/image-builds/trigger/environment/env-1", "POST")).status).toBe(200); + expect(TeamRepositoryGrantStore.prototype.listForTeam).toHaveBeenCalledWith("owner-team"); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledOnce(); + expect(mocks.triggerBuild).toHaveBeenCalledOnce(); + }); + + it("cannot build an environment repository missing the owner team's grant", async () => { + const response = await request("/image-builds/trigger/environment/env-1", "POST"); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ + error: "Target team lacks repository grant", + code: "target_team_missing_grant", + repository: "acme/repo", + }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).toHaveBeenCalledWith("owner-team"); + expect(mocks.triggerBuild).not.toHaveBeenCalled(); + }); + } +); diff --git a/packages/control-plane/src/routes/image-builds.trigger.test.ts b/packages/control-plane/src/routes/image-builds.trigger.test.ts index 878bb68fc2..c54cf4ab52 100644 --- a/packages/control-plane/src/routes/image-builds.trigger.test.ts +++ b/packages/control-plane/src/routes/image-builds.trigger.test.ts @@ -274,7 +274,10 @@ describe("POST /image-builds/trigger/repo/:owner/:name", () => { "modal-session-1" ); expect(modalClient.startImageBuildSandbox).toHaveBeenCalledTimes(1); - expect(scmProvider.generateCredentialHelperAuth).toHaveBeenCalled(); + expect(scmProvider.generateCredentialHelperAuth).toHaveBeenCalledExactlyOnceWith({ + kind: "repositories", + repositoryIds: [123], + }); // ...and is baked into the persisted fingerprint. expect(registerBuildSpy).toHaveBeenCalledWith( diff --git a/packages/control-plane/src/routes/image-builds.ts b/packages/control-plane/src/routes/image-builds.ts index 33c22fb244..22cfb607c5 100644 --- a/packages/control-plane/src/routes/image-builds.ts +++ b/packages/control-plane/src/routes/image-builds.ts @@ -18,6 +18,14 @@ import { repositoryShaEntrySchema, } from "@open-inspect/shared/types/image-builds"; import { z } from "zod"; +import { checkEnvironmentAccess } from "@open-inspect/shared"; +import { + admittedEnvironment, + evaluateEnvironmentAdmission, + ownedResourceAdmissionResponse, +} from "../authorization/owned-resource-admission"; +import { resourceViewer } from "../authorization/resource-viewer"; +import { EnvironmentStore } from "../db/environments"; import { ImageBuildStore } from "../db/image-builds"; import { RepoMetadataStore } from "../db/repo-metadata"; import { createLogger } from "../logger"; @@ -36,7 +44,9 @@ import { scheduleImageBuildOnSave } from "../image-builds/save-hooks"; import { listEnabledScopes, listEnabledScopeUnits, + type EnvironmentRowFilter, resolveScopeTarget, + type ResolvedImageBuildTarget, } from "../image-builds/scope"; import { createImageBuildWorkflowFromEnv } from "../image-builds/workflow"; import type { @@ -45,7 +55,6 @@ import type { ImageBuildWorkflowContext, } from "../image-builds/types"; import type { Env } from "../types"; -import type { SqlDatabase } from "../db/sql-database"; import { type RequestContext, GITHUB_USER_OR_SERVICE_ROUTE, @@ -54,8 +63,16 @@ import { json, NO_AUTHORIZATION, requirePermission, + resolveRepoOrError, + requireAll, + permissionRequirement, + environmentRequirement, } from "./shared"; import { parseQuery } from "./query"; +import { + authorizeTeamRepositories, + authorizeWorkspaceRepositories, +} from "./workspace-repository-authorization"; const logger = createLogger("router:image-builds"); const MAX_CALLBACK_BODY_BYTES = 16 * 1024; @@ -246,13 +263,14 @@ async function handleBuildFailed( async function triggerBuildForScope( env: Env, scope: ImageBuildScope, - ctx: RequestContext + ctx: RequestContext, + target?: ResolvedImageBuildTarget ): Promise { try { - const result = await createImageBuildWorkflowFromEnv(env, ctx.db).triggerBuild( - scope, - workflowContext(ctx) - ); + const workflow = createImageBuildWorkflowFromEnv(env, ctx.db); + const result = target + ? await workflow.triggerBuildWithTarget(scope, target, workflowContext(ctx)) + : await workflow.triggerBuild(scope, workflowContext(ctx)); if (result.type === "up_to_date") { // Unreachable via the trigger routes (triggerBuild is unconditional); // guards the union exhaustively. @@ -282,6 +300,30 @@ async function handleTriggerEnvironmentBuild( if (providerError) return providerError; const environmentId = params.id; + // Admission already loaded the environment and required manage access to it. + const { environment } = admittedEnvironment(ctx); + if (environment.owner_team_id !== null) { + const repositories = await new EnvironmentStore(ctx.db).getRepositoriesForEnvironment( + environmentId + ); + const resolved = await Promise.all( + repositories.map(async (repository) => { + const access = await resolveRepoOrError( + env, + repository.repo_owner, + repository.repo_name, + ctx, + logger + ); + return { owner: access.repoOwner, name: access.repoName, repoId: access.repoId }; + }) + ); + const denied = await authorizeTeamRepositories(ctx, { + teamId: environment.owner_team_id, + repositories: resolved, + }); + if (denied) return denied; + } return triggerBuildForScope(env, { kind: "environment", id: environmentId }, ctx); } @@ -301,8 +343,18 @@ async function handleTriggerRepoBuild( const repository = repositoryParams(params); if (repository instanceof Response) return repository; - - return triggerBuildForScope(env, repoImageBuildScope(repository.owner, repository.name), ctx); + const scope = repoImageBuildScope(repository.owner, repository.name); + try { + const target = await resolveScopeTarget(env, ctx.db, scope); + if (target.kind !== "repo") throw new Error("Expected repository image build scope"); + const denied = await authorizeWorkspaceRepositories(ctx, { + repositories: [{ owner: repository.owner, name: repository.name, repoId: target.repoId }], + }); + if (denied) return denied; + return triggerBuildForScope(env, scope, ctx, target); + } catch (e) { + return imageBuildErrorToResponse(e); + } } /** @@ -340,7 +392,12 @@ async function handleToggleRepoImageBuilds( // a repo that became unresolvable must remain disableable. if (body.enabled) { try { - await resolveScopeTarget(env, ctx.db, scope); + const target = await resolveScopeTarget(env, ctx.db, scope); + if (target.kind !== "repo") throw new Error("Expected repository image build scope"); + const denied = await authorizeWorkspaceRepositories(ctx, { + repositories: [{ owner, name, repoId: target.repoId }], + }); + if (denied) return denied; } catch (e) { return imageBuildErrorToResponse(e); } @@ -399,12 +456,24 @@ function parseScopeParams(request: Request): ImageBuildScope | null | Response { } async function readStatusRows( - db: SqlDatabase, + ctx: RequestContext, scope: ImageBuildScope | null ): Promise { - const store = new ImageBuildStore(db); + const store = new ImageBuildStore(ctx.db); if (scope) return store.getStatus(scope); - return store.getStatusForEnabledScopes(await listEnabledScopes(db)); + return store.getStatusForEnabledScopes( + await listEnabledScopes(ctx.db, await readableEnvironmentFilter(ctx)) + ); +} + +/** + * Mixed-scope feeds omit environments the feature-permitted viewer cannot read, before any + * per-environment work is done. + */ +async function readableEnvironmentFilter(ctx: RequestContext): Promise { + const viewer = await resourceViewer(ctx); + return (row) => + checkEnvironmentAccess(viewer, { ownerTeamId: row.owner_team_id }, "read").allowed; } /** @@ -427,9 +496,13 @@ async function handleGetStatus( const scope = parseScopeParams(request); if (scope instanceof Response) return scope; + if (scope?.kind === "environment") { + const admission = await evaluateEnvironmentAdmission(ctx, scope.id, "read"); + if (admission.kind !== "allowed") return ownedResourceAdmissionResponse(admission); + } try { - const body = { images: await readStatusRows(ctx.db, scope) } satisfies ImageBuildStatusResponse; + const body = { images: await readStatusRows(ctx, scope) } satisfies ImageBuildStatusResponse; return json(body); } catch (e) { logger.error("image_build.status_error", { @@ -456,7 +529,7 @@ async function handleGetEnabledUnits( if (providerError) return providerError; try { - const units = await listEnabledScopeUnits(env, ctx.db); + const units = await listEnabledScopeUnits(env, ctx.db, await readableEnvironmentFilter(ctx)); const admission = resolveImageBuildAdmission(env); return json({ units: units.map((unit) => ({ @@ -534,7 +607,10 @@ imageBuildRoutes.post( "/image-builds/trigger/environment/:id", admit({ ...GITHUB_USER_OR_SERVICE_ROUTE, - authorization: requirePermission("environments.images.manage"), + authorization: requireAll( + permissionRequirement("environments.images.manage"), + environmentRequirement("manage") + ), }), (c) => dispatch(c, handleTriggerEnvironmentBuild) ); diff --git a/packages/control-plane/src/routes/integration-settings.ts b/packages/control-plane/src/routes/integration-settings.ts index ea828e77e0..c407f120c3 100644 --- a/packages/control-plane/src/routes/integration-settings.ts +++ b/packages/control-plane/src/routes/integration-settings.ts @@ -34,6 +34,9 @@ import { json, error, requirePermission, + requireAll, + permissionRequirement, + environmentRequirement, } from "./shared"; import { parseJsonBody } from "./body"; @@ -505,7 +508,10 @@ const REPO_SETTINGS_MANAGE = admit({ }); const ENVIRONMENT_SETTINGS_MANAGE = admit({ ...GITHUB_USER_OR_SERVICE_ROUTE, - authorization: requirePermission("environments.settings.manage"), + authorization: requireAll( + permissionRequirement("environments.settings.manage"), + environmentRequirement("manage", "environmentId") + ), }); export const integrationSettingsRoutes = new Hono(); @@ -550,7 +556,13 @@ integrationSettingsRoutes.delete( // code-server, and VNC only) integrationSettingsRoutes.get( "/integration-settings/:id/environments/:environmentId", - INTEGRATIONS_READ, + admit({ + ...GITHUB_USER_OR_SERVICE_ROUTE, + authorization: requireAll( + permissionRequirement("integrations.read"), + environmentRequirement("read", "environmentId") + ), + }), (c) => dispatch(c, handleGetEnvironmentSettings) ); integrationSettingsRoutes.put( diff --git a/packages/control-plane/src/routes/repos.ts b/packages/control-plane/src/routes/repos.ts index daad4c0851..086f844ccf 100644 --- a/packages/control-plane/src/routes/repos.ts +++ b/packages/control-plane/src/routes/repos.ts @@ -8,17 +8,22 @@ import type { ControlPlaneHonoEnv } from "../routing/hono-env"; import { repositoryParams } from "./repository-params"; import { RepoMetadataStore } from "../db/repo-metadata"; import type { Env } from "../types"; +import { admitTeamCatalog, type TeamRepositoryGrants } from "./team-ownership"; import type { SqlDatabase } from "../db/sql-database"; import { - enrichedRepositorySchema, repoMetadataSchema, type EnrichedRepository, type InstallationRepository, type RepoMetadata, } from "@open-inspect/shared/types/repository-catalog"; -import { resolveScmProviderFromEnv, SourceControlProviderError } from "../source-control"; +import { + REPOS_CACHE_KEY, + cachedReposListSchema, + reposCacheIdentity, + type CachedReposList, +} from "../repos/cache"; +import { SourceControlProviderError } from "../source-control"; import { createLogger } from "../logger"; -import { z } from "zod"; import { GITHUB_USER_OR_SERVICE_ROUTE, type RequestContext, @@ -28,40 +33,13 @@ import { requirePermission, } from "./shared"; +export { REPOS_CACHE_KEY, reposCacheIdentity } from "../repos/cache"; + const logger = createLogger("router:repos"); -export const REPOS_CACHE_KEY = "repos:list:v3"; const REPOS_CACHE_FRESH_MS = 5 * 60 * 1000; const REPOS_CACHE_KV_TTL_SECONDS = 3600; -export async function reposCacheIdentity( - env: Pick< - Env, - "SCM_PROVIDER" | "GITHUB_APP_INSTALLATION_ID" | "GITLAB_NAMESPACE" | "GITLAB_ACCESS_TOKEN" - > -): Promise { - const provider = resolveScmProviderFromEnv(env.SCM_PROVIDER); - let identity: string[] = [provider]; - if (provider === "github") identity = [provider, env.GITHUB_APP_INSTALLATION_ID ?? ""]; - if (provider === "gitlab") { - identity = [provider, env.GITLAB_NAMESPACE ?? "", env.GITLAB_ACCESS_TOKEN ?? ""]; - } - const digest = new Uint8Array( - await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(identity))) - ); - return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join(""); -} - -const cachedReposListSchema = z.object({ - repos: z.array(enrichedRepositorySchema), - cachedAt: z.string(), - scmIdentity: z.string(), - // Missing in entries cached before this field was added. - freshUntil: z.number().optional(), -}); - -type CachedReposList = z.infer; - type ReposRefreshResult = | { ok: true; repos: EnrichedRepository[]; cachedAt: string } | { ok: false; reason: "not_configured" | "fetch_failed" }; @@ -164,6 +142,19 @@ async function handleListRepos( _params: object, ctx: RequestContext ): Promise { + const teamId = new URL(request.url).searchParams.get("teamId"); + let grants: TeamRepositoryGrants | undefined; + if (teamId !== null) { + const admitted = await admitTeamCatalog(request, ctx, teamId, "/repos"); + if (admitted instanceof Response) return admitted; + grants = admitted; + } + const filterRepos = (repos: EnrichedRepository[]) => { + if (!grants || grants.some((grant) => grant.grant_kind === "installation")) return repos; + const ids = new Set(grants.map((grant) => grant.repo_external_id)); + return repos.filter((repo) => ids.has(repo.id)); + }; + const teamScope = grants ? { teamHasRepositoryGrants: grants.length > 0 } : {}; const cacheStore = env.REPOS_CACHE; const scmIdentity = await reposCacheIdentity(env); @@ -194,7 +185,8 @@ async function handleListRepos( } return json({ - repos: cached.repos, + repos: filterRepos(cached.repos), + ...teamScope, cached: true, cachedAt: cached.cachedAt, }); @@ -225,7 +217,8 @@ async function handleListRepos( } return json({ - repos: result.repos, + repos: filterRepos(result.repos), + ...teamScope, cached: false, cachedAt: result.cachedAt, }); diff --git a/packages/control-plane/src/routes/repository-grants.test-support.ts b/packages/control-plane/src/routes/repository-grants.test-support.ts new file mode 100644 index 0000000000..42bcd38299 --- /dev/null +++ b/packages/control-plane/src/routes/repository-grants.test-support.ts @@ -0,0 +1,93 @@ +import { vi } from "vitest"; +import type { PermissionId } from "@open-inspect/shared/rbac"; +import type * as AuthenticateModule from "../auth/authenticate"; +import { createTestBackgroundTasks } from "../background-tasks.test-support"; +import type { SqlDatabase, SqlStatement } from "../db/sql-database"; +import { TeamMembershipStore } from "../db/team-memberships"; +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import { TeamStore } from "../db/teams"; +import { createControlPlaneApp, type RouteModule } from "../routing/hono-app"; +import type { Env } from "../types"; + +const mocks = vi.hoisted(() => ({ authenticate: vi.fn() })); +vi.mock("../auth/authenticate", async (importOriginal) => ({ + ...(await importOriginal()), + authenticate: mocks.authenticate, +})); + +/** Only admission queries have rows; domain stores are spied on by their suites. */ +export function authorizationDatabase({ + permissions, +}: { + permissions: readonly PermissionId[]; +}): SqlDatabase { + return { + prepare(sql) { + const statement: SqlStatement = { + bind: () => statement, + first: async () => + (sql.includes("FROM users u") + ? { + user_id: "user-1", + suspended_at: null, + role_id: "role-1", + role_key: null, + role_name: "Custom", + } + : null) as T | null, + all: async () => ({ + results: (sql.includes("FROM role_permissions") + ? permissions.map((permission_id) => ({ permission_id })) + : []) as T[], + meta: { changes: 0 }, + }), + run: async () => ({ results: [] as T[], meta: { changes: 0 } }), + }; + return statement; + }, + batch: async () => [], + }; +} + +export function createRepositoryGrantEnv(permissions: readonly PermissionId[]): Env { + return { + DB: authorizationDatabase({ permissions }), + SCM_PROVIDER: "github", + SANDBOX_PROVIDER: "modal", + TEAMS_ENFORCEMENT: "on", + REPO_SECRETS_ENCRYPTION_KEY: "test-key", + } as Env; +} + +/** Mount only the domain under test, without importing the production route catalog. */ +export function createRepositoryGrantRequest(module: RouteModule, env: () => Env) { + const app = createControlPlaneApp([module], { + backgroundTasks: () => createTestBackgroundTasks(), + }); + return (path: string, method: string, body?: unknown, environment = env()): Promise => + Promise.resolve( + app.fetch( + new Request(`https://test.local${path}`, { + method, + headers: { "Content-Type": "application/json", "If-Match": "revision-1" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }), + environment + ) + ); +} + +export function setupRepositoryGrantSpies(): void { + mocks.authenticate.mockImplementation(async (request: Request) => ({ + principal: { kind: "user", userId: "user-1" }, + request, + })); + vi.spyOn(TeamMembershipStore.prototype, "listForUser").mockResolvedValue( + new Map([["team-1", "member"]]) + ); + vi.spyOn(TeamStore.prototype, "isActive").mockResolvedValue(true); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([]); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listTeamsForRepository").mockResolvedValue([ + "other-team", + ]); +} diff --git a/packages/control-plane/src/routes/secrets.repository-grants.test.ts b/packages/control-plane/src/routes/secrets.repository-grants.test.ts new file mode 100644 index 0000000000..c044977bb3 --- /dev/null +++ b/packages/control-plane/src/routes/secrets.repository-grants.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BUILT_IN_ROLE_REGISTRY } from "@open-inspect/shared/rbac"; +import { + authorizationDatabase, + createRepositoryGrantEnv, + createRepositoryGrantRequest, + setupRepositoryGrantSpies, +} from "./repository-grants.test-support"; +import { AuthorizationStore } from "../db/authorization-store"; +import { RepoSecretsStore } from "../db/repo-secrets"; +import { GlobalSecretsStore } from "../db/global-secrets"; +import { TeamMembershipStore } from "../db/team-memberships"; +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import type * as SourceControlModule from "../source-control"; +import type { Env } from "../types"; +import { secretsRoutes } from "./secrets"; + +const mocks = vi.hoisted(() => ({ checkRepositoryAccess: vi.fn() })); +vi.mock("../source-control", async (importOriginal) => ({ + ...(await importOriginal()), + createSourceControlProviderFromEnv: () => ({ + name: "github", + checkRepositoryAccess: mocks.checkRepositoryAccess, + }), +})); + +const env = () => createRepositoryGrantEnv(["repositories.secrets.manage"]); +const request = createRepositoryGrantRequest(secretsRoutes, env); +const repositorySecretRequests = [ + ["GET", "/repos/acme/repo/secrets", undefined], + ["PUT", "/repos/acme/repo/secrets", { secrets: { KEY: "value" } }], + ["DELETE", "/repos/acme/repo/secrets/KEY", undefined], +] as const; + +beforeEach(() => { + vi.clearAllMocks(); + setupRepositoryGrantSpies(); + mocks.checkRepositoryAccess.mockResolvedValue({ + repoId: 123, + repoOwner: "acme", + repoName: "repo", + defaultBranch: "main", + }); + vi.spyOn(RepoSecretsStore.prototype, "setSecrets").mockResolvedValue({ + keys: ["KEY"], + created: 1, + updated: 0, + }); + vi.spyOn(RepoSecretsStore.prototype, "listSecretKeys").mockResolvedValue([]); + vi.spyOn(RepoSecretsStore.prototype, "deleteSecret").mockResolvedValue(true); + vi.spyOn(GlobalSecretsStore.prototype, "listSecretKeys").mockResolvedValue([]); +}); +afterEach(() => vi.restoreAllMocks()); + +describe("repository secret source grants", () => { + it.each(repositorySecretRequests)( + "denies %s source access for a granted member who is not a lead", + async (method, path, body) => { + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "team-1", + ]); + expect((await request(path, method, body)).status).toBe(403); + expect(RepoSecretsStore.prototype.listSecretKeys).not.toHaveBeenCalled(); + expect(RepoSecretsStore.prototype.setSecrets).not.toHaveBeenCalled(); + expect(RepoSecretsStore.prototype.deleteSecret).not.toHaveBeenCalled(); + } + ); + + it("denies an active lead without a covering source grant", async () => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["team-1", "lead"]]) + ); + expect((await request("/repos/acme/repo/secrets", "GET")).status).toBe(403); + expect(RepoSecretsStore.prototype.listSecretKeys).not.toHaveBeenCalled(); + }); + + it.each(repositorySecretRequests)( + "allows %s for an owning-team lead without adding repositories.use", + async (method, path, body) => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["team-1", "lead"]]) + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "other-team", + "team-1", + ]); + expect((await request(path, method, body)).status).toBe(200); + } + ); + + it("retains repositories.secrets.manage admission before grants", async () => { + const environment = env(); + environment.DB = authorizationDatabase({ permissions: [] }); + expect((await request("/repos/acme/repo/secrets", "GET", undefined, environment)).status).toBe( + 403 + ); + expect(mocks.checkRepositoryAccess).not.toHaveBeenCalled(); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).not.toHaveBeenCalled(); + }); +}); + +describe.each(["off", "shadow", "on"] as const)( + "workspace repository secret ownership in %s mode", + (mode) => { + let environment: Env; + beforeEach(() => { + environment = env(); + environment.TEAMS_ENFORCEMENT = mode; + }); + + it.each(repositorySecretRequests)( + "allows secret %s with no grants anywhere and only existing permissions", + async (method, path, body) => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue(new Map()); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([]); + expect((await request(path, method, body, environment)).status).toBe(200); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).toHaveBeenCalledWith(123); + } + ); + + it.each(["member", "lead", null] as const)( + "denies another team's repositories for an unrelated %s across secret routes", + async (role) => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + role === null ? new Map() : new Map([["team-1", role]]) + ); + for (const [method, path, body] of repositorySecretRequests) { + expect((await request(path, method, body, environment)).status).toBe(403); + } + expect(RepoSecretsStore.prototype.listSecretKeys).not.toHaveBeenCalled(); + expect(RepoSecretsStore.prototype.setSecrets).not.toHaveBeenCalled(); + expect(RepoSecretsStore.prototype.deleteSecret).not.toHaveBeenCalled(); + } + ); + } +); + +describe.each(["owner", "administrator"] as const)( + "built-in %s repository secret authorization", + (key) => { + beforeEach(() => { + vi.spyOn(AuthorizationStore.prototype, "getEffectiveAuthorization").mockResolvedValue({ + userId: "user-1", + suspendedAt: null, + role: { ...BUILT_IN_ROLE_REGISTRY[key], name: key }, + }); + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue(new Map()); + }); + + it.each(repositorySecretRequests)( + "allows secret %s for another team's repository without lead membership", + async (method, path, body) => { + expect((await request(path, method, body)).status).toBe(200); + } + ); + } +); diff --git a/packages/control-plane/src/routes/secrets.ts b/packages/control-plane/src/routes/secrets.ts index f752bc17f1..8104ed752c 100644 --- a/packages/control-plane/src/routes/secrets.ts +++ b/packages/control-plane/src/routes/secrets.ts @@ -12,6 +12,7 @@ import { GlobalSecretsStore } from "../db/global-secrets"; import { SecretsValidationError, normalizeKey, validateKey } from "../db/secrets-validation"; import type { Env } from "../types"; import { createLogger } from "../logger"; +import { authorizeWorkspaceRepositories } from "./workspace-repository-authorization"; import { GITHUB_USER_OR_SERVICE_ROUTE, type RequestContext, @@ -45,6 +46,11 @@ async function handleSetRepoSecrets( const { owner, name } = repository; const resolved = await resolveRepoOrError(env, owner, name, ctx, logger); + const denied = await authorizeWorkspaceRepositories(ctx, { + repositories: [{ owner: resolved.repoOwner, name: resolved.repoName, repoId: resolved.repoId }], + requireLead: true, + }); + if (denied) return denied; const body = await parseBody( request, @@ -119,6 +125,11 @@ async function handleListRepoSecrets( const { owner, name } = repository; const resolved = await resolveRepoOrError(env, owner, name, ctx, logger); + const denied = await authorizeWorkspaceRepositories(ctx, { + repositories: [{ owner: resolved.repoOwner, name: resolved.repoName, repoId: resolved.repoId }], + requireLead: true, + }); + if (denied) return denied; const store = new RepoSecretsStore(ctx.db, env.REPO_SECRETS_ENCRYPTION_KEY); const globalStore = new GlobalSecretsStore(ctx.db, env.REPO_SECRETS_ENCRYPTION_KEY); @@ -189,6 +200,11 @@ async function handleDeleteRepoSecret( } const resolved = await resolveRepoOrError(env, owner, name, ctx, logger); + const denied = await authorizeWorkspaceRepositories(ctx, { + repositories: [{ owner: resolved.repoOwner, name: resolved.repoName, repoId: resolved.repoId }], + requireLead: true, + }); + if (denied) return denied; const store = new RepoSecretsStore(ctx.db, env.REPO_SECRETS_ENCRYPTION_KEY); diff --git a/packages/control-plane/src/routes/session-child-spawn.ts b/packages/control-plane/src/routes/session-child-spawn.ts index 3a598aa8ad..43b10f25b9 100644 --- a/packages/control-plane/src/routes/session-child-spawn.ts +++ b/packages/control-plane/src/routes/session-child-spawn.ts @@ -39,10 +39,11 @@ import { permissionRequirement, sessionRequirement, requireAll, + resolveRepoOrError, } from "./shared"; import { type SessionRouteContext, dispatchSession } from "./session-route"; import { DEFAULT_BASE_BRANCH } from "../repos/default-branch"; -import { authorizeSessionTarget } from "./session-target-authorization"; +import { authorizeEnvironmentTarget, authorizeSessionTarget } from "./session-target-authorization"; const logger = createLogger("router:session-child-spawn"); const MAX_SPAWN_DEPTH = 2; @@ -74,6 +75,22 @@ export async function handleSpawnChild( const parentSession = await sessionStore.get(parentId); const parentEnvironmentId = parentSession?.environmentId ?? null; + // Reject an incompatible inherited environment before settings resolution or child admission. + // The permission preflight runs first so a missing grant keeps its permission_required shape. + if (parentEnvironmentId) { + const permissionError = await authorizeSessionTarget(ctx, { + teamId: null, + environmentId: parentEnvironmentId, + }); + if (permissionError) return permissionError; + const environmentError = await authorizeEnvironmentTarget(ctx, { + environmentId: parentEnvironmentId, + ownerTeamId: parentSession?.ownerTeamId ?? null, + inherited: true, + }); + if (environmentError) return environmentError; + } + // Children inherit the parent's settings scope: its primary repo plus, for // environment-launched parents, that environment's overrides (design §13.5). const resolvedChildSandboxSettings = parentSession @@ -155,12 +172,31 @@ export async function handleSpawnChild( } } - const targetAuthorizationError = authorizeSessionTarget(ctx, { + const inheritedRepositories = + parentRepoOwner && parentRepoName ? [{ owner: parentRepoOwner, name: parentRepoName }] : []; + const targetAuthorizationError = await authorizeSessionTarget(ctx, { + teamId: null, environmentId: parentEnvironmentId, - hasRepository: Boolean(parentRepoOwner && parentRepoName), + repositories: inheritedRepositories, }); if (targetAuthorizationError) return targetAuthorizationError; + const teamId = parentSession?.ownerTeamId ?? null; + let childRepoId = spawnContext.repoId; + if (teamId && parentRepoOwner && parentRepoName) { + const resolved = await resolveRepoOrError(env, parentRepoOwner, parentRepoName, ctx, logger); + childRepoId = resolved.repoId; + } + const resolvedTargetAuthorizationError = await authorizeSessionTarget(ctx, { + teamId, + environmentId: parentEnvironmentId, + repositories: inheritedRepositories.map((repository) => ({ + ...repository, + repoId: childRepoId, + })), + }); + if (resolvedTargetAuthorizationError) return resolvedTargetAuthorizationError; + let enabledModels: ValidModel[]; try { enabledModels = await getEffectiveEnabledModels(ctx.db); @@ -256,7 +292,7 @@ export async function handleSpawnChild( sessionId: childId, repoOwner: spawnContext.repoOwner, repoName: spawnContext.repoName, - repoId: spawnContext.repoId, + repoId: childRepoId, environmentId: parentEnvironmentId, branch: spawnContext.repoOwner && spawnContext.repoName diff --git a/packages/control-plane/src/routes/session-children.test.ts b/packages/control-plane/src/routes/session-children.test.ts index 7d07dbabce..d23911b857 100644 --- a/packages/control-plane/src/routes/session-children.test.ts +++ b/packages/control-plane/src/routes/session-children.test.ts @@ -49,6 +49,16 @@ function routeContext( }; } +function sandboxRouteContext( + fetch: SessionRuntimeClient["fetch"], + promptAuthor?: ActivePromptAuthor +): SessionRouteContext { + return { + ...routeContext(fetch, promptAuthor), + principal: { kind: "sandbox", sessionId: "parent" }, + }; +} + describe("handleListChildren", () => { afterEach(() => vi.restoreAllMocks()); @@ -223,12 +233,15 @@ describe("handlePromptChild", () => { id: "child", parentSessionId: "parent", status: "completed", + ownerTeamId: null, + visibility: "workspace", } as never) .mockResolvedValueOnce({ id: "parent", repoOwner: "acme", repoName: "repo", environmentId: "env-1", + ownerTeamId: null, } as never); vi.mocked(resolveSandboxSettings).mockResolvedValue({ maxConcurrentChildSessions: 2 }); const lease = { token: "lease-1", childSessionId: "child", expiresAt: Date.now() + 60_000 }; @@ -252,7 +265,7 @@ describe("handlePromptChild", () => { "/sessions/parent/children/child/prompt", "/sessions/:id/children/:childId/prompt" ), - routeContext(fetch) + sandboxRouteContext(fetch) ); expect(response.status).toBe(200); @@ -263,11 +276,15 @@ describe("handlePromptChild", () => { }); it("does not resolve policy or reserve capacity for an active child", async () => { - vi.spyOn(SessionIndexStore.prototype, "get").mockResolvedValue({ - id: "child", - parentSessionId: "parent", - status: "active", - } as never); + vi.spyOn(SessionIndexStore.prototype, "get") + .mockResolvedValueOnce({ + id: "child", + parentSessionId: "parent", + status: "active", + ownerTeamId: null, + visibility: "workspace", + } as never) + .mockResolvedValueOnce({ id: "parent", ownerTeamId: null } as never); const reserve = vi.spyOn(SessionIndexStore.prototype, "acquireChildAdmissionLease"); const childResponse = Response.json({ messageId: "message-1", status: "queued" }); const fetch = vi.fn(async () => childResponse); @@ -282,7 +299,7 @@ describe("handlePromptChild", () => { "/sessions/parent/children/child/prompt", "/sessions/:id/children/:childId/prompt" ), - routeContext(fetch) + sandboxRouteContext(fetch) ); expect(response).toBe(childResponse); @@ -291,11 +308,15 @@ describe("handlePromptChild", () => { }); it("accepts the child response when the best-effort message id payload is malformed", async () => { - vi.spyOn(SessionIndexStore.prototype, "get").mockResolvedValue({ - id: "child", - parentSessionId: "parent", - status: "active", - } as never); + vi.spyOn(SessionIndexStore.prototype, "get") + .mockResolvedValueOnce({ + id: "child", + parentSessionId: "parent", + status: "active", + ownerTeamId: null, + visibility: "workspace", + } as never) + .mockResolvedValueOnce({ id: "parent", ownerTeamId: null } as never); vi.spyOn(SessionIndexStore.prototype, "touchUpdatedAt").mockResolvedValue(true); const childResponse = new Response("[]", { status: 200, @@ -313,7 +334,7 @@ describe("handlePromptChild", () => { "/sessions/parent/children/child/prompt", "/sessions/:id/children/:childId/prompt" ), - routeContext(fetch) + sandboxRouteContext(fetch) ); expect(response).toBe(childResponse); @@ -321,11 +342,15 @@ describe("handlePromptChild", () => { }); it("forwards the parent active prompt author to the child", async () => { - vi.spyOn(SessionIndexStore.prototype, "get").mockResolvedValue({ - id: "child", - parentSessionId: "parent", - status: "active", - } as never); + vi.spyOn(SessionIndexStore.prototype, "get") + .mockResolvedValueOnce({ + id: "child", + parentSessionId: "parent", + status: "active", + ownerTeamId: null, + visibility: "workspace", + } as never) + .mockResolvedValueOnce({ id: "parent", ownerTeamId: null } as never); const promptAuthor = { userId: "slack:U2", canonicalUserId: "canonical-2", @@ -359,7 +384,7 @@ describe("handlePromptChild", () => { "/sessions/parent/children/child/prompt", "/sessions/:id/children/:childId/prompt" ), - routeContext(fetch, promptAuthor) + sandboxRouteContext(fetch, promptAuthor) ); expect(response.status).toBe(200); @@ -372,8 +397,10 @@ describe("handlePromptChild", () => { id: "child", parentSessionId: "parent", status: "failed", + ownerTeamId: null, + visibility: "workspace", } as never) - .mockResolvedValueOnce({ id: "parent" } as never); + .mockResolvedValueOnce({ id: "parent", ownerTeamId: null } as never); vi.mocked(resolveSandboxSettings).mockResolvedValue({ maxConcurrentChildSessions: 1 }); vi.spyOn(SessionIndexStore.prototype, "acquireChildAdmissionLease").mockResolvedValue(null); const fetch = vi.fn(); @@ -388,7 +415,7 @@ describe("handlePromptChild", () => { "/sessions/parent/children/child/prompt", "/sessions/:id/children/:childId/prompt" ), - routeContext(fetch) + sandboxRouteContext(fetch) ); expect(response.status).toBe(429); @@ -401,8 +428,10 @@ describe("handlePromptChild", () => { id: "child", parentSessionId: "parent", status: "completed", + ownerTeamId: null, + visibility: "workspace", } as never) - .mockResolvedValueOnce({ id: "parent" } as never); + .mockResolvedValueOnce({ id: "parent", ownerTeamId: null } as never); vi.mocked(resolveSandboxSettings).mockResolvedValue({ maxConcurrentChildSessions: 2 }); const lease = { token: "lease-1", childSessionId: "child", expiresAt: Date.now() + 60_000 }; vi.spyOn(SessionIndexStore.prototype, "acquireChildAdmissionLease").mockResolvedValue(lease); @@ -422,7 +451,7 @@ describe("handlePromptChild", () => { "/sessions/parent/children/child/prompt", "/sessions/:id/children/:childId/prompt" ), - routeContext(fetch) + sandboxRouteContext(fetch) ); expect(response).toBe(childResponse); @@ -435,8 +464,10 @@ describe("handlePromptChild", () => { id: "child", parentSessionId: "parent", status: "completed", + ownerTeamId: null, + visibility: "workspace", } as never) - .mockResolvedValueOnce({ id: "parent" } as never); + .mockResolvedValueOnce({ id: "parent", ownerTeamId: null } as never); vi.mocked(resolveSandboxSettings).mockResolvedValue({ maxConcurrentChildSessions: 2 }); const lease = { token: "lease-1", childSessionId: "child", expiresAt: Date.now() + 60_000 }; vi.spyOn(SessionIndexStore.prototype, "acquireChildAdmissionLease").mockResolvedValue(lease); @@ -459,7 +490,7 @@ describe("handlePromptChild", () => { "/sessions/parent/children/child/prompt", "/sessions/:id/children/:childId/prompt" ), - routeContext(fetch) + sandboxRouteContext(fetch) ) ).rejects.toBe(fetchError); diff --git a/packages/control-plane/src/routes/session-children.ts b/packages/control-plane/src/routes/session-children.ts index 07ef52e952..c5e9c398f2 100644 --- a/packages/control-plane/src/routes/session-children.ts +++ b/packages/control-plane/src/routes/session-children.ts @@ -52,9 +52,11 @@ function sandboxChildAccess( return async (child: SessionEntry, action: SessionAction): Promise => { if (ctx.principal?.kind !== "sandbox" || ctx.principal.sessionId !== parent.id) return false; if (child.ownerTeamId !== parent.ownerTeamId) return false; + // Non-read actions on team-owned children need the active prompt author's current membership. if ( - child.visibility === "workspace" || - (child.visibility === "team" && parent.visibility === "team") + (action === "read" || child.ownerTeamId === null) && + (child.visibility === "workspace" || + (child.visibility === "team" && parent.visibility === "team")) ) return true; @@ -210,10 +212,7 @@ export async function handlePromptChild( if (!authorResponse.ok) return authorResponse; const author = activePromptAuthorSchema.safeParse(await authorResponse.json()); if (!author.success) return error("Failed to get active prompt author", 500); - if ( - childSession.visibility === "private" && - !(await sandboxChildAccess(ctx, parentSession, author.data)(childSession, "collaborate")) - ) + if (!(await sandboxChildAccess(ctx, parentSession, author.data)(childSession, "collaborate"))) return error("Child session not found", 404); let admissionLease: ChildAdmissionLease | null = null; diff --git a/packages/control-plane/src/routes/session-create.ts b/packages/control-plane/src/routes/session-create.ts index 9193bb494a..d80d0b6834 100644 --- a/packages/control-plane/src/routes/session-create.ts +++ b/packages/control-plane/src/routes/session-create.ts @@ -18,10 +18,7 @@ import { resolveEnvironmentTarget, resolveSessionRepositories } from "../repos/r import { resolveScmProviderFromEnv } from "../source-control"; import { EnvironmentStore } from "../db/environments"; import { UserStore } from "../db/user-store"; -import { TeamStore } from "../db/teams"; import { TeamMembershipStore } from "../db/team-memberships"; -import { TeamSettingsStore } from "../db/team-settings"; -import { missingTeamRepository } from "./session-team-grants"; import { createLogger } from "../logger"; import { parseCreateSessionInput } from "../session/create-session-input"; import { @@ -35,7 +32,8 @@ import { resolveManagedSkills, SkillResolutionError } from "../session/skill-res import type { Env } from "../types"; import { resolveSessionProviderAuth } from "../session/provider-account-resolution"; import { ProviderAccountSelectionPolicyError } from "../model-provider-accounts/selection-policy"; -import { authorizeSessionTarget } from "./session-target-authorization"; +import { authorizeEnvironmentTarget, authorizeSessionTarget } from "./session-target-authorization"; +import { resolveCreationOwnerTeam, teamRequiredResponse } from "./team-ownership"; import { normalizeOptionalRepositoryPair, RepositoryPairValidationError, @@ -116,11 +114,21 @@ export async function handleCreateSession( throw e; } - const targetAuthorizationError = authorizeSessionTarget(ctx, { + const targetAuthorizationError = await authorizeSessionTarget(ctx, { + teamId: null, environmentId: body.environmentId, - hasRepository: Boolean(repositoryContext || body.repositories), + repositories: (body.repositories ?? (repositoryContext ? [repositoryContext] : [])).map( + (repository) => ({ owner: repository.repoOwner, name: repository.repoName }) + ), }); if (targetAuthorizationError) return targetAuthorizationError; + if (body.environmentId) { + const environmentError = await authorizeEnvironmentTarget(ctx, { + environmentId: body.environmentId, + ownerTeamId: body.teamId ?? null, + }); + if (environmentError) return environmentError; + } // Validate branch names if provided (defense in depth) if (body.branch && !BRANCH_NAME_PATTERN.test(body.branch)) { @@ -182,39 +190,30 @@ export async function handleCreateSession( if (resolution instanceof Response) return resolution; const resolvedUserId = resolution; const teamId = body.teamId ?? null; - if (!teamId && (await new TeamSettingsStore(ctx.db).get()).requireTeamOnCreate) { - return json({ error: "A team is required", code: "team_required" }, 400); - } - let team = null; + const team = await resolveCreationOwnerTeam(ctx, teamId); + if (team instanceof Response) return team; if (teamId) { - team = await new TeamStore(ctx.db).getById(teamId); - if (!team) return error("Team not found", 404); - if (team.archivedAt !== null) - return json({ error: "Team archived", code: "team_archived" }, 409); if ( !resolvedUserId || !(await new TeamMembershipStore(ctx.db).listForUser(resolvedUserId)).has(teamId) ) { return json({ error: "Not a team member", code: "not_member" }, 403); } - const missing = await missingTeamRepository( - ctx.db, - teamId, - repositories ?? (repoOwner && repoName ? [{ repoOwner, repoName, repoId }] : []) - ); - if (missing) - return json( - { - error: "Target team lacks repository grant", - code: "target_team_missing_grant", - repository: `${missing.repoOwner}/${missing.repoName}`, - }, - 409 - ); } + const resolvedTargetAuthorizationError = await authorizeSessionTarget(ctx, { + teamId, + environmentId, + repositories: ( + repositories ?? (repoOwner && repoName ? [{ repoOwner, repoName, repoId }] : []) + ).map((repository) => ({ + owner: repository.repoOwner, + name: repository.repoName, + repoId: repository.repoId, + })), + }); + if (resolvedTargetAuthorizationError) return resolvedTargetAuthorizationError; const visibility = body.visibility ?? team?.defaultVisibility ?? "workspace"; - if (visibility === "team" && !teamId) - return json({ error: "A team is required", code: "team_required" }, 400); + if (visibility === "team" && !teamId) return teamRequiredResponse(); if (visibility === "private" && !resolvedUserId) return json({ error: "Session owner required", code: "owner_required" }, 400); diff --git a/packages/control-plane/src/routes/session-index.ts b/packages/control-plane/src/routes/session-index.ts index f69c95ae64..8b2dbe7ee3 100644 --- a/packages/control-plane/src/routes/session-index.ts +++ b/packages/control-plane/src/routes/session-index.ts @@ -1,3 +1,4 @@ +import { isWorkspaceAdmin } from "@open-inspect/shared/rbac"; import { parseBody } from "./body"; import { Hono } from "hono"; import { z } from "zod"; @@ -154,10 +155,7 @@ export async function handleListSessions( )) : new Map() ); - if ( - scope === "all" && - (viewer.kind !== "user" || !["owner", "administrator"].includes(viewer.roleKey ?? "")) - ) { + if (scope === "all" && (viewer.kind !== "user" || !isWorkspaceAdmin(viewer.roleKey))) { return error("Invalid scope", 403); } if (ownerFilter && ownerFilter !== "anyone" && viewer.kind !== "user") { @@ -252,10 +250,7 @@ export async function handleListSessionInbox( ctx.principal.userId )) ); - if ( - scope === "all" && - (viewer.kind !== "user" || !["owner", "administrator"].includes(viewer.roleKey ?? "")) - ) { + if (scope === "all" && (viewer.kind !== "user" || !isWorkspaceAdmin(viewer.roleKey))) { return error("Invalid scope", 403); } diff --git a/packages/control-plane/src/routes/session-target-authorization.test.ts b/packages/control-plane/src/routes/session-target-authorization.test.ts new file mode 100644 index 0000000000..ffbeb99dea --- /dev/null +++ b/packages/control-plane/src/routes/session-target-authorization.test.ts @@ -0,0 +1,241 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PermissionId } from "@open-inspect/shared/rbac"; +import type { Principal } from "../auth/principal"; +import { createRequestMetrics } from "../db/instrumented-sql-database"; +import { TeamStore } from "../db/teams"; +import type { SqlStatement } from "../db/sql-database"; +import { + createTestEnv, + emptyStatement, + TEST_BACKGROUND_TASK_CONTEXT, +} from "../router.test-support"; +import type { RequestContext } from "./shared"; +import { authorizeSessionTarget, type SessionTarget } from "./session-target-authorization"; + +const repository = { owner: "acme/group", name: "app", repoId: 7 }; +const target: SessionTarget = { teamId: "team_alpha", repositories: [repository] }; +const user: Principal = { kind: "user", userId: "user-1" }; +const sandbox: Principal = { kind: "sandbox", sessionId: "parent-1" }; + +function context( + principal: Principal = user, + permissions: PermissionId[] = ["repositories.use", "environments.use"], + grants: readonly { + grant_kind: "installation" | "repository"; + repo_external_id: number | null; + }[] = [] +): RequestContext { + const statement: SqlStatement = { + ...emptyStatement(), + bind: () => statement, + all: async () => ({ + results: [...grants] as T[], + meta: { changes: 0 }, + }), + }; + return { + request_id: "request-1", + trace_id: "trace-1", + metrics: createRequestMetrics(), + executionCtx: TEST_BACKGROUND_TASK_CONTEXT, + db: { ...createTestEnv().DB, prepare: vi.fn(() => statement) }, + principal, + authorization: { + userId: "user-1", + suspendedAt: null, + role: { id: "role-1", key: null, name: "Test" }, + permissions, + }, + }; +} + +describe("authorizeSessionTarget", () => { + beforeEach(() => { + vi.spyOn(TeamStore.prototype, "isActive").mockResolvedValue(true); + }); + afterEach(() => vi.restoreAllMocks()); + + it.each([user, { kind: "service", service: "slack-bot", actor: null } satisfies Principal])( + "checks $kind repository permission before looking up grants", + async (principal) => { + const ctx = context(principal, []); + const response = await authorizeSessionTarget(ctx, target); + + expect(response?.status).toBe(403); + await expect(response?.json()).resolves.toEqual({ + error: "Forbidden", + code: "permission_required", + permission: "repositories.use", + }); + expect(ctx.db.prepare).not.toHaveBeenCalled(); + expect(TeamStore.prototype.isActive).not.toHaveBeenCalled(); + } + ); + + it("requires environment permission instead of repository permission", async () => { + const ctx = context(user, ["repositories.use"]); + const response = await authorizeSessionTarget(ctx, { ...target, environmentId: "env_1" }); + + expect(response?.status).toBe(403); + await expect(response?.json()).resolves.toMatchObject({ permission: "environments.use" }); + expect(ctx.db.prepare).not.toHaveBeenCalled(); + expect(TeamStore.prototype.isActive).not.toHaveBeenCalled(); + }); + + it("checks environment member grants without requiring repository permission", async () => { + const response = await authorizeSessionTarget(context(user, ["environments.use"]), { + ...target, + environmentId: "env_1", + }); + + expect(response?.status).toBe(409); + await expect(response?.json()).resolves.toEqual({ + error: "Target team lacks repository grant", + code: "target_team_missing_grant", + repository: "acme/group/app", + }); + }); + + it("fails closed when user authorization is unavailable", async () => { + const ctx = context(); + delete ctx.authorization; + const response = await authorizeSessionTarget(ctx, target); + + expect(response?.status).toBe(503); + await expect(response?.json()).resolves.toMatchObject({ code: "authorization_unavailable" }); + expect(ctx.db.prepare).not.toHaveBeenCalled(); + }); + + it("enforces the service capability ceiling before actor permissions", async () => { + const ctx = context({ kind: "service", service: "web", actor: null }); + const response = await authorizeSessionTarget(ctx, target); + + expect(response?.status).toBe(403); + await expect(response?.json()).resolves.toMatchObject({ code: "service_capability_required" }); + expect(ctx.db.prepare).not.toHaveBeenCalled(); + }); + + it("bypasses grants for workspace-owned targets", async () => { + const ctx = context(); + expect(await authorizeSessionTarget(ctx, { ...target, teamId: null })).toBeNull(); + expect(ctx.db.prepare).not.toHaveBeenCalled(); + expect(TeamStore.prototype.isActive).not.toHaveBeenCalled(); + }); + + it.each([{ repositories: undefined }, { repositories: [] }])( + "refuses inactive ownership for a repo-less target", + async ({ repositories }) => { + const ctx = context(user, []); + vi.mocked(TeamStore.prototype.isActive).mockResolvedValue(false); + + const denied = await authorizeSessionTarget(ctx, { teamId: "team_alpha", repositories }); + expect(denied?.status).toBe(403); + await expect(denied?.json()).resolves.toMatchObject({ code: "team_not_active" }); + expect(ctx.db.prepare).not.toHaveBeenCalled(); + expect(TeamStore.prototype.isActive).toHaveBeenCalledWith("team_alpha"); + } + ); + + it("preflights environment permission without team lookup when repository members are absent", async () => { + const ctx = context(user, ["environments.use"]); + + expect(await authorizeSessionTarget(ctx, { teamId: null, environmentId: "env_1" })).toBeNull(); + expect(TeamStore.prototype.isActive).not.toHaveBeenCalled(); + expect(ctx.db.prepare).not.toHaveBeenCalled(); + }); + + it.each([user, sandbox])("rejects an inactive target team for $kind", async (principal) => { + const ctx = context(principal); + vi.mocked(TeamStore.prototype.isActive).mockResolvedValue(false); + + const response = await authorizeSessionTarget(ctx, target); + + expect(response?.status).toBe(403); + await expect(response?.json()).resolves.toEqual({ + error: "Team is not active", + code: "team_not_active", + }); + expect(TeamStore.prototype.isActive).toHaveBeenCalledWith("team_alpha"); + expect(ctx.db.prepare).not.toHaveBeenCalled(); + }); + + it("matches grants by numeric repository ID", async () => { + const ctx = context( + user, + ["repositories.use"], + [{ grant_kind: "repository", repo_external_id: 7 }] + ); + expect(await authorizeSessionTarget(ctx, target)).toBeNull(); + }); + + it("reports the first uncovered repository in target order", async () => { + const ctx = context( + user, + ["repositories.use"], + [{ grant_kind: "repository", repo_external_id: 7 }] + ); + const response = await authorizeSessionTarget(ctx, { + ...target, + repositories: [repository, { owner: "acme", name: "api", repoId: 8 }], + }); + expect(response?.status).toBe(409); + await expect(response?.json()).resolves.toMatchObject({ repository: "acme/api" }); + }); + + it.each([null, undefined])( + "fails closed for unresolved ID %s without an installation grant", + async (repoId) => { + const ctx = context( + user, + ["repositories.use"], + [{ grant_kind: "repository", repo_external_id: 7 }] + ); + const response = await authorizeSessionTarget(ctx, { + ...target, + repositories: [{ ...repository, repoId }], + }); + expect(response?.status).toBe(409); + } + ); + + it.each([user, sandbox])( + "allows an active repo-less team target for $kind without grants", + async (principal) => { + const ctx = context(principal, []); + expect( + await authorizeSessionTarget(ctx, { teamId: "team_alpha", repositories: [] }) + ).toBeNull(); + expect(TeamStore.prototype.isActive).toHaveBeenCalledWith("team_alpha"); + expect(ctx.db.prepare).not.toHaveBeenCalled(); + } + ); + + it("allows unresolved IDs only with an installation grant", async () => { + const ctx = context( + user, + ["repositories.use"], + [{ grant_kind: "installation", repo_external_id: null }] + ); + expect( + await authorizeSessionTarget(ctx, { + ...target, + repositories: [{ owner: "acme", name: "app" }], + }) + ).toBeNull(); + }); + + it("still checks team grants for a sandbox without user permissions", async () => { + const ctx = context(sandbox, []); + delete ctx.authorization; + const response = await authorizeSessionTarget(ctx, target); + + expect(response?.status).toBe(409); + await expect(response?.json()).resolves.toMatchObject({ code: "target_team_missing_grant" }); + }); + + it("allows a sandbox with a covered team target without user authorization", async () => { + const ctx = context(sandbox, [], [{ grant_kind: "repository", repo_external_id: 7 }]); + delete ctx.authorization; + expect(await authorizeSessionTarget(ctx, target)).toBeNull(); + }); +}); diff --git a/packages/control-plane/src/routes/session-target-authorization.ts b/packages/control-plane/src/routes/session-target-authorization.ts index db1923185f..142a80829a 100644 --- a/packages/control-plane/src/routes/session-target-authorization.ts +++ b/packages/control-plane/src/routes/session-target-authorization.ts @@ -1,37 +1,96 @@ import type { PermissionId } from "@open-inspect/shared/rbac"; +import { isSelfActingPrincipal } from "../auth/principal"; +import { + evaluateEnvironmentAdmission, + ownedResourceAdmissionResponse, +} from "../authorization/owned-resource-admission"; import { serviceAllowsPermission } from "../authorization/service-permissions"; +import { EnvironmentStore, type EnvironmentRow } from "../db/environments"; import { json, type RequestContext } from "./shared"; +import { authorizeTeamRepositories } from "./workspace-repository-authorization"; export interface SessionTarget { + /** Team whose repository grants are checked; null during preflight, before IDs resolve. */ + teamId: string | null; environmentId?: string | null; - hasRepository: boolean; + repositories?: readonly { owner: string; name: string; repoId?: number | null }[]; } -/** Enforce use of the environment or repository inherited by a new session. */ -export function authorizeSessionTarget( +/** + * Permission and repository-grant checks for a session or automation target. Preflight with + * teamId: null; check team grants after resolving repository IDs. This does not admit the + * environment resource itself: launches must also call {@link authorizeEnvironmentTarget}. + */ +export async function authorizeSessionTarget( ctx: RequestContext, target: SessionTarget -): Response | null { - if (ctx.principal?.kind !== "user" && ctx.principal?.kind !== "service") return null; - +): Promise { const permission: PermissionId | null = target.environmentId ? "environments.use" - : target.hasRepository + : target.repositories?.length ? "repositories.use" : null; - if (!permission) return null; - if ( - ctx.principal.kind === "service" && - !serviceAllowsPermission(ctx.principal.service, permission) - ) { - return json({ error: "Forbidden", code: "service_capability_required" }, 403); - } - if (!ctx.authorization) { - return json({ error: "Authorization unavailable", code: "authorization_unavailable" }, 503); + // An access token is its owner, so it faces the checks that owner would: skipping them would + // let the credential aim an automation at repositories and environments the user may not use. + if (permission && (isSelfActingPrincipal(ctx.principal) || ctx.principal?.kind === "service")) { + if ( + ctx.principal.kind === "service" && + !serviceAllowsPermission(ctx.principal.service, permission) + ) { + return json({ error: "Forbidden", code: "service_capability_required" }, 403); + } + if (!ctx.authorization) { + return json({ error: "Authorization unavailable", code: "authorization_unavailable" }, 503); + } + if (!ctx.authorization.permissions.includes(permission)) { + return json({ error: "Forbidden", code: "permission_required", permission }, 403); + } } - if (!ctx.authorization.permissions.includes(permission)) { - return json({ error: "Forbidden", code: "permission_required", permission }, 403); + + return authorizeTeamRepositories(ctx, { + teamId: target.teamId, + repositories: (target.repositories ?? []).map((repository) => ({ + owner: repository.owner, + name: repository.name, + repoId: repository.repoId ?? null, + })), + }); +} + +/** + * Admit the environment a session launches with and bind its ownership to the session: the + * caller must be able to use it, and a team environment must belong to the session's team. + * Inherited targets (a child's parent environment) are provenance rather than a new choice, so + * they tolerate an environment that has since been deleted; `EnvironmentStore.delete` leaves + * sessions' environment IDs dangling. Sandboxes also skip use admission. + */ +export async function authorizeEnvironmentTarget( + ctx: RequestContext, + target: { environmentId: string; ownerTeamId: string | null; inherited?: boolean } +): Promise { + const sandbox = ctx.principal?.kind === "sandbox"; + if (sandbox || target.inherited) { + const environment = await new EnvironmentStore(ctx.db).getById(target.environmentId); + if (!environment) return null; + if (sandbox) return environmentTeamMismatch(environment, target.ownerTeamId); } - return null; + const admission = await evaluateEnvironmentAdmission(ctx, target.environmentId, "use"); + if (admission.kind !== "allowed") return ownedResourceAdmissionResponse(admission); + return environmentTeamMismatch(admission.admission.environment, target.ownerTeamId); +} + +function environmentTeamMismatch( + environment: EnvironmentRow, + ownerTeamId: string | null +): Response | null { + if (environment.owner_team_id === null || environment.owner_team_id === ownerTeamId) return null; + return json( + { + error: "Environment must belong to the session's owner team", + code: "environment_team_mismatch", + reason_code: "environment_team_mismatch", + }, + 409 + ); } diff --git a/packages/control-plane/src/routes/shared.ts b/packages/control-plane/src/routes/shared.ts index 5d43c33484..a3d11165f5 100644 --- a/packages/control-plane/src/routes/shared.ts +++ b/packages/control-plane/src/routes/shared.ts @@ -43,11 +43,12 @@ export type RouteAuthorizationRequirement = | { kind: "permission"; permission: PermissionId } | { kind: "automation"; - operation: "manage" | "trigger"; + operation: "read" | "manage" | "trigger"; automationIdParam: string; } | { kind: "team"; teamIdParam: string; need: keyof TeamCapabilities | "read" | "member" } | { kind: "team"; teamIdParam: string; need: "removeMember"; targetUserIdParam: string } + | { kind: "environment"; idParam: string; need: "read" | "manage" | "use" } | { kind: "session"; sessionIdParam: string; action: SessionAction; enforceAlways?: boolean }; type BotServiceName = Exclude; @@ -148,6 +149,8 @@ function auditsAllowedRequirement(requirement: RouteAuthorizationRequirement): b if (requirement.kind === "team") { return requirement.need !== "read" && requirement.need !== "member"; } + if (requirement.kind === "automation") return requirement.operation !== "read"; + if (requirement.kind === "environment") return requirement.need !== "read"; if (requirement.kind === "permission") { return AUDITED_ALLOWED_PERMISSIONS.has(requirement.permission); } @@ -175,23 +178,46 @@ export function requirePermission( }; } -/** Require admission to manage or trigger the automation identified by a path parameter. */ +/** Require admission to the automation identified by a path parameter. */ export function requireAutomation( - operation: "manage" | "trigger", + operation: "read" | "manage" | "trigger", automationIdParam = "id" ): RouteAuthorization { return { kind: "active-user", allOf: [{ kind: "automation", operation, automationIdParam }], - service: { kind: "deny" }, - auditAllowed: true, + service: + operation === "read" + ? { kind: "actor", actorlessGrants: [{ service: "slack-bot" }] } + : { kind: "deny" }, + auditAllowed: operation !== "read", + }; +} + +export function environmentRequirement( + need: "read" | "manage" | "use", + idParam = "id" +): Extract { + return { kind: "environment", idParam, need }; +} + +export function requireEnvironment( + need: "read" | "manage" | "use", + idParam = "id", + options?: { actorlessGrants?: readonly ActorlessServiceGrant[] } +): RouteAuthorization { + return { + kind: "active-user", + allOf: [environmentRequirement(need, idParam)], + service: need === "manage" ? { kind: "deny" } : { kind: "actor", ...options }, + auditAllowed: need !== "read", }; } export function requireTeam( need: keyof TeamCapabilities | "read" | "member", options?: { teamIdParam?: string; auditAllowed?: boolean } -): RouteAuthorization { +): Extract { const requirement: RouteAuthorizationRequirement = { kind: "team", teamIdParam: options?.teamIdParam ?? "id", diff --git a/packages/control-plane/src/routes/skills.repository-grants.test.ts b/packages/control-plane/src/routes/skills.repository-grants.test.ts new file mode 100644 index 0000000000..d0d02cbae9 --- /dev/null +++ b/packages/control-plane/src/routes/skills.repository-grants.test.ts @@ -0,0 +1,435 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BUILT_IN_ROLE_REGISTRY } from "@open-inspect/shared/rbac"; +import { + skillImportPreviewResponseSchema, + skillSchema, + type SkillAssignmentInput, +} from "@open-inspect/shared/types/skills"; +import { + createRepositoryGrantEnv, + createRepositoryGrantRequest, + setupRepositoryGrantSpies, +} from "./repository-grants.test-support"; +import { AuthorizationStore } from "../db/authorization-store"; +import { SkillStore, SkillValidationError } from "../db/skills"; +import { TeamMembershipStore } from "../db/team-memberships"; +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import { SourceControlProviderError } from "../source-control"; +import type * as SourceControlModule from "../source-control"; +import type { Env } from "../types"; +import { skillRoutes } from "./skills"; + +const mocks = vi.hoisted(() => ({ + checkRepositoryAccess: vi.fn(), + resolveCommit: vi.fn(), + listTree: vi.fn(), + readBlob: vi.fn(), +})); +vi.mock("../source-control", async (importOriginal) => ({ + ...(await importOriginal()), + createSourceControlProviderFromEnv: () => ({ + name: "github", + checkRepositoryAccess: mocks.checkRepositoryAccess, + resolveCommit: mocks.resolveCommit, + listTree: mocks.listTree, + readBlob: mocks.readBlob, + }), +})); + +const env = () => createRepositoryGrantEnv(["skills.manage"]); +const request = createRepositoryGrantRequest(skillRoutes, env); +const content = { description: "Deploy a service", body: "# Deploy", metadata: {}, files: [] }; +const source = { + provider: "github", + repoOwner: "acme", + repoName: "repo", + requestedRef: null, + resolvedRef: "main", + commitSha: "a".repeat(40), + subdirectory: null, + sourceSha256: "b".repeat(64), + importedAt: 1, + revisionId: "revision-1", +}; +const skill = skillSchema.parse({ + id: "skill-1", + name: "deploy", + ...content, + enabled: true, + currentRevisionId: "revision-1", + revisionNumber: 1, + revisionSha256: "c".repeat(64), + revisionCreatedBy: "user-1", + creatorDisplayName: null, + lastEditorDisplayName: null, + revisionAuthorDisplayName: null, + assignments: [], + source, + createdBy: "user-1", + updatedBy: "user-1", + createdAt: 1, + updatedAt: 1, + license: null, + compatibility: null, +}); +const importSource = { repository: { repoOwner: "acme", repoName: "repo" } }; +const confirmation = { + expectedCommitSha: "a".repeat(40), + expectedSourceSha256: "b".repeat(64), + expectedRevisionSha256: "c".repeat(64), +}; +const assignment: SkillAssignmentInput = { + type: "repository", + repository: { repoOwner: "acme", repoName: "repo", baseBranch: null }, +}; +const skillAssignmentWrites = [ + ["POST", "/skills", { name: "deploy", content, assignments: [assignment] }, 201], + ["PUT", "/skills/skill-1", { content, assignments: [assignment] }, 200], +] as const; +const skillImportRequests = [ + ["/skills/import/preview", { source: importSource }], + ["/skills/import", { source: importSource, ...confirmation }], + ["/skills/skill-1/reimport/preview", {}], + ["/skills/skill-1/reimport", confirmation], +] as const; + +async function expectAllowedSkillImports(environment = env()): Promise { + const preview = await request( + "/skills/import/preview", + "POST", + { source: importSource }, + environment + ); + expect(preview.status).toBe(200); + const imported = skillImportPreviewResponseSchema.parse(await preview.json()); + const currentConfirmation = { + expectedCommitSha: imported.source.commitSha, + expectedSourceSha256: imported.source.sourceSha256, + expectedRevisionSha256: imported.revisionSha256, + }; + expect( + ( + await request( + "/skills/import", + "POST", + { source: importSource, assignments: [assignment], ...currentConfirmation }, + environment + ) + ).status + ).toBe(201); + const reimportPreview = await request( + "/skills/skill-1/reimport/preview", + "POST", + {}, + environment + ); + expect(reimportPreview.status).toBe(200); + const reimported = skillImportPreviewResponseSchema.parse(await reimportPreview.json()); + expect( + ( + await request( + "/skills/skill-1/reimport", + "POST", + { + expectedCommitSha: reimported.source.commitSha, + expectedSourceSha256: reimported.source.sourceSha256, + expectedRevisionSha256: reimported.revisionSha256, + }, + environment + ) + ).status + ).toBe(200); + expect(SkillStore.prototype.create).toHaveBeenCalledOnce(); + expect(SkillStore.prototype.applyImportedRevision).toHaveBeenCalledOnce(); +} + +beforeEach(() => { + vi.clearAllMocks(); + setupRepositoryGrantSpies(); + mocks.checkRepositoryAccess.mockResolvedValue({ + repoId: 123, + repoOwner: "acme", + repoName: "repo", + defaultBranch: "main", + }); + mocks.resolveCommit.mockResolvedValue({ sha: "a".repeat(40) }); + mocks.listTree.mockResolvedValue({ + entries: [ + { + path: "SKILL.md", + type: "file", + blobId: "blob", + sizeBytes: 50, + executable: false, + }, + ], + truncated: false, + }); + mocks.readBlob.mockResolvedValue( + new TextEncoder().encode("---\nname: deploy\ndescription: Deploy a service\n---\n# Deploy\n") + ); + vi.spyOn(SkillStore.prototype, "create").mockResolvedValue(skill); + vi.spyOn(SkillStore.prototype, "get").mockResolvedValue(skill); + vi.spyOn(SkillStore.prototype, "replaceContentAndAssignments").mockResolvedValue(skill); + vi.spyOn(SkillStore.prototype, "applyImportedRevision").mockResolvedValue({ + skill, + revisionCreated: true, + }); + vi.spyOn(SkillStore.prototype, "nameAvailable").mockResolvedValue(true); +}); +afterEach(() => vi.restoreAllMocks()); + +describe("skill repository-bearing writes", () => { + it.each(skillAssignmentWrites)( + "denies %s %s with another team's assignment before persistence", + async (method, path, body) => { + expect((await request(path, method, body)).status).toBe(403); + expect(SkillStore.prototype.create).not.toHaveBeenCalled(); + expect(SkillStore.prototype.replaceContentAndAssignments).not.toHaveBeenCalled(); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledWith({ owner: "acme", name: "repo" }); + } + ); + + it.each(skillImportRequests)( + "denies source access at %s before reading repository content", + async (path, body) => { + expect((await request(path, "POST", body)).status).toBe(403); + expect(mocks.resolveCommit).not.toHaveBeenCalled(); + expect(mocks.listTree).not.toHaveBeenCalled(); + expect(mocks.readBlob).not.toHaveBeenCalled(); + expect(SkillStore.prototype.create).not.toHaveBeenCalled(); + expect(SkillStore.prototype.applyImportedRevision).not.toHaveBeenCalled(); + } + ); + + it.each(skillAssignmentWrites)( + "allows %s %s for an owning-team member with skills.manage alone", + async (method, path, body, status) => { + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "other-team", + "team-1", + ]); + expect((await request(path, method, body)).status).toBe(status); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).toHaveBeenCalledWith(123); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + } + ); + + it("resolves assignments sequentially", async () => { + let activeLookups = 0; + let peakLookups = 0; + mocks.checkRepositoryAccess.mockImplementation( + async ({ owner, name }: { owner: string; name: string }) => { + activeLookups++; + peakLookups = Math.max(peakLookups, activeLookups); + await Promise.resolve(); + activeLookups--; + return { + repoId: name === "other" ? 456 : 123, + repoOwner: owner, + repoName: name, + defaultBranch: "main", + }; + } + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "team-1", + ]); + const response = await request("/skills", "POST", { + name: "deploy", + content, + assignments: [ + assignment, + { type: "repository", repository: { repoOwner: "acme", repoName: "other" } }, + ], + }); + + expect(response.status).toBe(201); + expect(peakLookups).toBe(1); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledTimes(2); + }); + + it("resolves duplicate repositories once without removing assignments from store validation", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "team-1", + ]); + vi.mocked(SkillStore.prototype.create).mockRejectedValue( + new SkillValidationError("Skill assignments must be unique") + ); + const assignments = [assignment, assignment]; + + expect( + (await request("/skills", "POST", { name: "deploy", content, assignments })).status + ).toBe(400); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledOnce(); + expect(SkillStore.prototype.create).toHaveBeenCalledWith( + expect.objectContaining({ assignments }), + "user-1" + ); + }); + + it.each([ + { failure: new SourceControlProviderError("SCM unavailable", "transient", 503), status: 503 }, + { + failure: new SourceControlProviderError("SCM rejected access", "permanent", 401), + status: 502, + }, + ])( + "preserves importer resolution errors ($status) before grants and content reads", + async ({ failure, status }) => { + mocks.checkRepositoryAccess.mockRejectedValue(failure); + for (const [path, body] of skillImportRequests) { + const response = await request(path, "POST", body); + expect(response.status).toBe(status); + await expect(response.json()).resolves.toEqual({ + error: `Failed to reach acme/repo: ${failure.message}`, + }); + } + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).not.toHaveBeenCalled(); + expect(mocks.resolveCommit).not.toHaveBeenCalled(); + expect(mocks.readBlob).not.toHaveBeenCalled(); + expect(SkillStore.prototype.create).not.toHaveBeenCalled(); + expect(SkillStore.prototype.applyImportedRevision).not.toHaveBeenCalled(); + } + ); + + it("preserves the importer-specific inaccessible repository response", async () => { + mocks.checkRepositoryAccess.mockResolvedValue(null); + const response = await request("/skills/import/preview", "POST", { source: importSource }); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ + error: + "acme/repo is not accessible to this installation. Grant the app access to the repository and try again.", + }); + expect(mocks.readBlob).not.toHaveBeenCalled(); + }); + + it("rejects an uninstalled assignment before persistence even with an installation grant", async () => { + mocks.checkRepositoryAccess.mockResolvedValue(null); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "team-1", + ]); + expect( + (await request("/skills", "POST", { name: "deploy", content, assignments: [assignment] })) + .status + ).toBe(404); + expect(SkillStore.prototype.create).not.toHaveBeenCalled(); + }); + + it("rejects a replaced numeric source ID rather than trusting recorded provenance names", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockImplementation( + async (repoId) => (repoId === 456 ? ["team-1"] : ["other-team"]) + ); + expect((await request("/skills/skill-1/reimport", "POST", confirmation)).status).toBe(403); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledWith({ owner: "acme", name: "repo" }); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).toHaveBeenCalledWith(123); + expect(mocks.readBlob).not.toHaveBeenCalled(); + expect(SkillStore.prototype.applyImportedRevision).not.toHaveBeenCalled(); + }); + + it("checks import assignments independently of a granted source", async () => { + mocks.checkRepositoryAccess.mockImplementation( + async ({ owner, name }: { owner: string; name: string }) => ({ + repoId: name === "source" ? 456 : 123, + repoOwner: owner, + repoName: name, + defaultBranch: "main", + }) + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockImplementation( + async (repoId) => (repoId === 456 ? ["team-1"] : ["other-team"]) + ); + expect( + ( + await request("/skills/import", "POST", { + source: { repository: { repoOwner: "acme", repoName: "source" } }, + assignments: [assignment], + ...confirmation, + }) + ).status + ).toBe(403); + expect(mocks.readBlob).not.toHaveBeenCalled(); + expect(SkillStore.prototype.create).not.toHaveBeenCalled(); + }); + + it("allows an owning-team member to preview and confirm import and reimport", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "team-1", + ]); + await expectAllowedSkillImports(); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledTimes(5); + }); +}); + +describe.each(["off", "shadow", "on"] as const)( + "workspace skill repository ownership in %s mode", + (mode) => { + let environment: Env; + beforeEach(() => { + environment = env(); + environment.TEAMS_ENFORCEMENT = mode; + }); + + it.each(skillAssignmentWrites)( + "allows %s %s with no grants anywhere and only existing permissions", + async (method, path, body, status) => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue(new Map()); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([]); + expect((await request(path, method, body, environment)).status).toBe(status); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).toHaveBeenCalledWith(123); + } + ); + + it("allows import and reimport preview confirmation with no grants anywhere", async () => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue(new Map()); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([]); + await expectAllowedSkillImports(environment); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).toHaveBeenCalledWith(123); + }); + + it.each(["member", "lead", null] as const)( + "denies another team's repositories for an unrelated %s across skill routes", + async (role) => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + role === null ? new Map() : new Map([["team-1", role]]) + ); + for (const [method, path, body] of skillAssignmentWrites) { + expect((await request(path, method, body, environment)).status).toBe(403); + } + for (const [path, body] of skillImportRequests) { + expect((await request(path, "POST", body, environment)).status).toBe(403); + } + expect(SkillStore.prototype.create).not.toHaveBeenCalled(); + expect(SkillStore.prototype.replaceContentAndAssignments).not.toHaveBeenCalled(); + expect(SkillStore.prototype.applyImportedRevision).not.toHaveBeenCalled(); + expect(mocks.readBlob).not.toHaveBeenCalled(); + } + ); + } +); + +describe.each(["owner", "administrator"] as const)( + "built-in %s skill repository authorization", + (key) => { + beforeEach(() => { + vi.spyOn(AuthorizationStore.prototype, "getEffectiveAuthorization").mockResolvedValue({ + userId: "user-1", + suspendedAt: null, + role: { ...BUILT_IN_ROLE_REGISTRY[key], name: key }, + }); + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue(new Map()); + }); + + it.each(skillAssignmentWrites)( + "allows %s %s for another team's repository without membership", + async (method, path, body, status) => { + expect((await request(path, method, body)).status).toBe(status); + } + ); + + it("allows import and reimport preview confirmation for another team's repository", async () => { + await expectAllowedSkillImports(); + }); + } +); diff --git a/packages/control-plane/src/routes/skills.ts b/packages/control-plane/src/routes/skills.ts index adde8db143..012e142a15 100644 --- a/packages/control-plane/src/routes/skills.ts +++ b/packages/control-plane/src/routes/skills.ts @@ -24,6 +24,10 @@ import { } from "../db/skill-profiles"; import { SkillConflictError, SkillStore, SkillValidationError } from "../db/skills"; import { canonicalUserIdOf } from "../auth/principal"; +import { + evaluateEnvironmentAdmission, + ownedResourceAdmissionResponse, +} from "../authorization/owned-resource-admission"; import { EnvironmentStore } from "../db/environments"; import { resolveManagedSkills, SkillResolutionError } from "../session/skill-resolution"; import type { Env } from "../types"; @@ -32,7 +36,12 @@ import { buildValidatedSkillRevision, SkillRevisionValidationError, } from "../skills/content-addressing"; -import { fetchSkillImport, SkillImportError, type SkillImportResult } from "../skills/git-import"; +import { + fetchSkillImport, + resolveSkillImportRepository, + SkillImportError, + type SkillImportResult, +} from "../skills/git-import"; import { admit, dispatch } from "../routing/admit"; import type { ControlPlaneHonoEnv } from "../routing/hono-env"; import { z } from "zod"; @@ -45,7 +54,12 @@ import { SCM_AGNOSTIC_HUMAN_USER_ROUTE, SCM_AGNOSTIC_USER_OR_SERVICE_ROUTE, requirePermission, + resolveRepoOrError, } from "./shared"; +import { + authorizeWorkspaceRepositories, + type RepositoryAuthorizationTarget, +} from "./workspace-repository-authorization"; const log = createLogger("router:skills"); @@ -128,7 +142,7 @@ async function handleGetSkill( async function handleCreateSkill( request: Request, - _env: Env, + env: Env, _params: object, ctx: RequestContext ): Promise { @@ -136,6 +150,14 @@ async function handleCreateSkill( if (!userId) return error("Canonical user required", 403); const parsed = await parseBody(request, createSkillInputSchema, "Invalid skill"); if (parsed instanceof Response) return parsed; + const denied = await authorizeSkillRepositories( + env, + ctx, + parsed.assignments.flatMap((assignment) => + assignment.type === "repository" ? [assignment.repository] : [] + ) + ); + if (denied) return denied; try { const skill = await new SkillStore(ctx.db).create(parsed, userId); audit(ctx, { @@ -149,6 +171,45 @@ async function handleCreateSkill( } } +async function authorizeSkillRepositories( + env: Env, + ctx: RequestContext, + repositories: readonly { repoOwner: string; repoName: string }[] +): Promise { + if (repositories.length === 0) return null; + const resolved: RepositoryAuthorizationTarget[] = []; + const seen = new Set(); + for (const repository of repositories) { + const key = `${repository.repoOwner}/${repository.repoName}`; + if (seen.has(key)) continue; + seen.add(key); + const access = await resolveRepoOrError( + env, + repository.repoOwner, + repository.repoName, + ctx, + log + ); + resolved.push({ owner: access.repoOwner, name: access.repoName, repoId: access.repoId }); + } + return authorizeWorkspaceRepositories(ctx, { repositories: resolved }); +} + +async function fetchAuthorizedSkillImport( + env: Env, + ctx: RequestContext, + source: SkillImportSourceInput, + name?: string | null +): Promise { + const provider = createRouteSourceControlProvider(env); + const resolved = await resolveSkillImportRepository(provider, source); + const denied = await authorizeWorkspaceRepositories(ctx, { + repositories: [{ owner: resolved.repoOwner, name: resolved.repoName, repoId: resolved.repoId }], + }); + if (denied) return denied; + return fetchSkillImport(provider, source, name, resolved); +} + async function handlePreviewSkill( request: Request, _env: Env, @@ -243,11 +304,8 @@ async function handlePreviewSkillImport( ); if (parsed instanceof Response) return parsed; try { - const result = await fetchSkillImport( - createRouteSourceControlProvider(env), - parsed.source, - parsed.name - ); + const result = await fetchAuthorizedSkillImport(env, ctx, parsed.source, parsed.name); + if (result instanceof Response) return result; return json(await importPreviewResponse(ctx, result)); } catch (e) { return skillImportWriteError(e); @@ -264,12 +322,17 @@ async function handleImportSkill( if (!userId) return error("Canonical user required", 403); const parsed = await parseBody(request, importSkillInputSchema, "Invalid skill import"); if (parsed instanceof Response) return parsed; + const denied = await authorizeSkillRepositories( + env, + ctx, + parsed.assignments.flatMap((assignment) => + assignment.type === "repository" ? [assignment.repository] : [] + ) + ); + if (denied) return denied; try { - const result = await fetchSkillImport( - createRouteSourceControlProvider(env), - parsed.source, - parsed.name - ); + const result = await fetchAuthorizedSkillImport(env, ctx, parsed.source, parsed.name); + if (result instanceof Response) return result; const stale = confirmedImport(result, parsed); if (stale) return stale; const skill = await new SkillStore(ctx.db).create( @@ -337,7 +400,8 @@ async function handlePreviewSkillReimport( const provider = createRouteSourceControlProvider(env); const source = recordedImportSource(skill.source, parsed.ref, provider.name); if (source instanceof Response) return source; - const result = await fetchSkillImport(provider, source, skill.name); + const result = await fetchAuthorizedSkillImport(env, ctx, source, skill.name); + if (result instanceof Response) return result; return json(await importPreviewResponse(ctx, result, skill.name)); } catch (e) { return skillImportWriteError(e); @@ -367,7 +431,8 @@ async function handleReimportSkill( const provider = createRouteSourceControlProvider(env); const source = recordedImportSource(skill.source, parsed.ref, provider.name); if (source instanceof Response) return source; - const result = await fetchSkillImport(provider, source, skill.name); + const result = await fetchAuthorizedSkillImport(env, ctx, source, skill.name); + if (result instanceof Response) return result; const stale = confirmedImport(result, parsed); if (stale) return stale; const applied = await store.applyImportedRevision( @@ -424,7 +489,7 @@ async function handleSetSkillEnabled( async function handleReplaceSkillContentAndAssignments( request: Request, - _env: Env, + env: Env, params: { id: string }, ctx: RequestContext ): Promise { @@ -439,6 +504,14 @@ async function handleReplaceSkillContentAndAssignments( "Invalid skill edit" ); if (parsed instanceof Response) return parsed; + const denied = await authorizeSkillRepositories( + env, + ctx, + parsed.assignments.flatMap((assignment) => + assignment.type === "repository" ? [assignment.repository] : [] + ) + ); + if (denied) return denied; try { const skill = await new SkillStore(ctx.db).replaceContentAndAssignments( id, @@ -559,10 +632,9 @@ async function handleResolvePreview( ? [{ repoOwner: parsed.repoOwner, repoName: parsed.repoName }] : []); if (parsed.environmentId) { + const admission = await evaluateEnvironmentAdmission(ctx, parsed.environmentId, "read"); + if (admission.kind !== "allowed") return ownedResourceAdmissionResponse(admission); const environments = new EnvironmentStore(ctx.db); - if (!(await environments.getById(parsed.environmentId))) { - return error("Environment not found", 404); - } repositories = (await environments.getRepositoriesForEnvironment(parsed.environmentId)).map( (repository) => ({ repoOwner: repository.repo_owner, diff --git a/packages/control-plane/src/routes/team-ownership.ts b/packages/control-plane/src/routes/team-ownership.ts new file mode 100644 index 0000000000..c02187abe6 --- /dev/null +++ b/packages/control-plane/src/routes/team-ownership.ts @@ -0,0 +1,94 @@ +/** + * Team scoping shared by creation routes (which team will own the new resource) and catalog + * routes (which resources a team's sessions may launch with). + */ + +import { isWorkspaceAdmin } from "@open-inspect/shared/rbac"; +import type { Team } from "@open-inspect/shared/types/teams"; +import { auditRouteAuthorizationDecision } from "../authorization/request-audit"; +import { TeamMembershipStore } from "../db/team-memberships"; +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import { TeamSettingsStore } from "../db/team-settings"; +import { TeamStore } from "../db/teams"; +import { error, json } from "../http/responses"; +import type { RequestContext } from "../http/request-context"; + +export type TeamRepositoryGrants = Awaited>; + +export function teamRequiredResponse(): Response { + return json({ error: "A team is required", code: "team_required" }, 400); +} + +/** + * Validate the owner team named when creating a resource: the workspace may require one, and + * archived teams accept no new resources. Resolves to null for workspace ownership. Callers + * still decide whether the creator may act for the team. + */ +export async function resolveCreationOwnerTeam( + ctx: RequestContext, + ownerTeamId: string | null +): Promise { + if (ownerTeamId === null) { + return (await new TeamSettingsStore(ctx.db).get()).requireTeamOnCreate + ? teamRequiredResponse() + : null; + } + return resolveActiveTeam(ctx, ownerTeamId); +} + +/** Resolve a team that will own or keep owning a resource; archived teams accept no changes. */ +export async function resolveActiveTeam( + ctx: RequestContext, + teamId: string +): Promise { + const team = await new TeamStore(ctx.db).getById(teamId); + if (!team) return error("Team not found", 404); + if (team.archivedAt !== null) { + return json( + { error: "Team archived", code: "team_archived", reason_code: "team_archived" }, + 409 + ); + } + return team; +} + +/** + * Admit a `?teamId=` session catalog and return the team's repository grants. The team must be + * active and the caller a member or workspace admin; hidden teams are audited and answered + * like missing ones. + */ +export async function admitTeamCatalog( + request: Request, + ctx: RequestContext, + catalogTeamId: string, + path: string +): Promise { + const authorization = ctx.authorization; + const roleKey = authorization?.role.key; + const allowed = + !!authorization && + (await new TeamStore(ctx.db).isActive(catalogTeamId)) && + (isWorkspaceAdmin(roleKey) || + (ctx.sessionMemberships ??= await new TeamMembershipStore(ctx.db).listForUser( + authorization.userId + )).has(catalogTeamId)); + if (!allowed) { + const response = error("Team not found", 404); + await auditRouteAuthorizationDecision({ + ctx, + method: request.method, + path, + response, + teamId: catalogTeamId, + decision: { + kind: "denied", + reasonCode: "team_not_visible", + reason: "Team not found", + requirements: [{ kind: "team", teamIdParam: "teamId", need: "member" }], + effectivePermissions: [], + }, + }); + return response; + } + return new TeamRepositoryGrantStore(ctx.db).listForTeam(catalogTeamId); +} diff --git a/packages/control-plane/src/routes/teams.ts b/packages/control-plane/src/routes/teams.ts index 912b643479..6e41639809 100644 --- a/packages/control-plane/src/routes/teams.ts +++ b/packages/control-plane/src/routes/teams.ts @@ -1,12 +1,14 @@ +import { isWorkspaceAdmin } from "@open-inspect/shared/rbac"; import { Hono } from "hono"; import { z } from "zod"; import { resolveTeamAccess } from "@open-inspect/shared/types/team-access"; import { createTeamRequestSchema, - teamMembershipSchema, teamRoleSchema, teamSessionsResponseSchema, updateTeamRequestSchema, + addTeamRepositoryGrantRequestSchema, + teamRepositoryGrantsResponseSchema, type Team, type TeamRole, } from "@open-inspect/shared/types/teams"; @@ -24,7 +26,7 @@ import { SessionIndexStore } from "../db/session-index"; import { SessionCollaboratorStore } from "../db/session-collaborators"; import { encodeSessionInboxCursor, parseSessionInboxCursor } from "../db/session-inbox-cursor"; import type { ScopedInboxSession, ListSessionInboxResult } from "../db/session-inbox-store"; -import { TeamAuditStore, type TeamAuditInput } from "../db/team-audit"; +import type { TeamAuditActor } from "../db/team-audit"; import { LastLeadError, TeamMembershipNotFoundError, @@ -32,6 +34,10 @@ import { } from "../db/team-memberships"; import { TeamSlugConflictError, TeamStore } from "../db/teams"; import { TeamSettingsStore } from "../db/team-settings"; +import { + TeamRepositoryGrantConflictError, + TeamRepositoryGrantStore, +} from "../db/team-repository-grants"; import type { RequestContext } from "../http/request-context"; import { admit, dispatch } from "../routing/admit"; import type { ControlPlaneHonoEnv } from "../routing/hono-env"; @@ -48,9 +54,13 @@ import { requireTeam, requireAll, permissionRequirement, + resolveRepoOrError, + type RouteAuthorization, } from "./shared"; +import { createLogger } from "../logger"; const PRIVATE = { cacheControl: "private, no-store" } as const; +const logger = createLogger("router:teams"); const ACTIVE_USER = { kind: "active-global", service: { kind: "deny" }, @@ -99,22 +109,14 @@ function admittedTeam(ctx: RequestContext): Team { return ctx.teamAdmission.team; } -async function auditTeamEvent( - input: Omit & { - ctx: RequestContext; - team: Team; - } -): Promise { - const { ctx, team, ...event } = input; - await new TeamAuditStore(ctx.db).write({ - ...event, - requestId: ctx.request_id, - actorUserId: viewer(ctx).userId, - teamId: team.id, - }); +function auditActor(ctx: RequestContext): TeamAuditActor { + return { requestId: ctx.request_id, actorUserId: viewer(ctx).userId }; } function mutationError(cause: unknown): Response { + if (cause instanceof TeamRepositoryGrantConflictError) { + return json({ error: cause.message, code: cause.code }, 409); + } if (cause instanceof LastLeadError) return json({ error: cause.message, code: "last_lead" }, 409); if (cause instanceof TeamMembershipNotFoundError) return error("Team membership not found", 404); if (cause instanceof TeamSlugConflictError) { @@ -130,7 +132,7 @@ async function listTeams(request: Request, _env: Env, _params: object, ctx: Requ const query = parseQuery(request, querySchema); if (query instanceof Response) return query; const subject = viewer(ctx); - const isAdmin = subject.roleKey === "owner" || subject.roleKey === "administrator"; + const isAdmin = isWorkspaceAdmin(subject.roleKey); const membershipStore = new TeamMembershipStore(ctx.db); const memberships = await membershipStore.listForUser(subject.userId); const teams = await new TeamStore(ctx.db).list({ @@ -291,11 +293,11 @@ async function updateTeam( if (body instanceof Response) return body; const before = admittedTeam(ctx); try { - const team = await new TeamStore(ctx.db).update(before.id, body); + const team = await new TeamStore(ctx.db).update(before.id, body, { + ...auditActor(ctx), + before, + }); if (!team) return error("Team not found", 404); - if (Object.keys(body).length > 0) { - await auditTeamEvent({ ctx, team, action: "team.updated", before, after: team }); - } return json(await responseTeam(ctx, team)); } catch (cause) { return mutationError(cause); @@ -311,16 +313,9 @@ async function setArchived( ) { const before = admittedTeam(ctx); const store = new TeamStore(ctx.db); - const changed = archive ? await store.archive(before.id) : await store.restore(before.id); + const audit = { ...auditActor(ctx), before }; + await (archive ? store.archive(before.id, audit) : store.restore(before.id, audit)); const team = (await store.getById(before.id))!; - if (changed) - await auditTeamEvent({ - ctx, - team, - action: archive ? "team.archived" : "team.restored", - before, - after: team, - }); return json(await responseTeam(ctx, team)); } @@ -358,22 +353,15 @@ async function putMember( return json({ member }); } try { - if (before) await store.setRole(team.id, params.userId, body.role); - else if (!(await store.add(team.id, params.userId, body.role))) { + if (before) + await store.setRole(team.id, params.userId, body.role, { ...auditActor(ctx), before }); + else if (!(await store.add(team.id, params.userId, body.role, "manual", auditActor(ctx)))) { return json({ error: "Membership changed concurrently", code: "membership_conflict" }, 409); } - const after = (await store.listMembersWithUsers(team.id, { includeEmail })).find( - (member) => member.userId === params.userId - )!; - await auditTeamEvent({ - ctx, - team, - targetUserId: params.userId, - action: before ? "team.member_role_changed" : "team.member_added", - before: before ?? {}, - after: teamMembershipSchema.parse(after), - }); - return json({ member: after }); + const member = (await store.listMembersWithUsers(team.id, { includeEmail })).find( + (row) => row.userId === params.userId + ); + return json({ member }); } catch (cause) { return mutationError(cause); } @@ -392,15 +380,7 @@ async function deleteMember( ); if (!before) return error("Team membership not found", 404); try { - await store.remove(team.id, params.userId); - await auditTeamEvent({ - ctx, - team, - targetUserId: params.userId, - action: "team.member_removed", - before, - after: {}, - }); + await store.remove(team.id, params.userId, { ...auditActor(ctx), before }); return new Response(null, { status: 204 }); } catch (cause) { return mutationError(cause); @@ -415,22 +395,75 @@ async function joinTeam( ) { const team = admittedTeam(ctx); const userId = viewer(ctx).userId; - if (!(await new TeamMembershipStore(ctx.db).addIfJoinable(team.id, userId))) { + if (!(await new TeamMembershipStore(ctx.db).addIfJoinable(team.id, userId, auditActor(ctx)))) { return json({ error: "Team join is no longer available", code: "join_unavailable" }, 409); } - await auditTeamEvent({ - ctx, - team, - targetUserId: userId, - action: "team.member_joined", - before: {}, - after: { userId, role: "member" }, - }); return json(await responseTeam(ctx, team)); } +async function repositoryGrants( + _request: Request, + _env: Env, + _params: { id: string }, + ctx: RequestContext +) { + return json( + teamRepositoryGrantsResponseSchema.parse({ + grants: await new TeamRepositoryGrantStore(ctx.db).listDetailsForTeam(admittedTeam(ctx).id), + }) + ); +} + +async function putRepositoryGrant( + request: Request, + env: Env, + _params: { id: string }, + ctx: RequestContext +) { + const body = await parseBody(request, addTeamRepositoryGrantRequestSchema); + if (body instanceof Response) return body; + const team = admittedTeam(ctx); + if (body.kind === "repository") { + const repository = await resolveRepoOrError(env, body.owner, body.name, ctx, logger); + if (repository.repoId !== body.repoExternalId) { + return json( + { error: "Repository identity changed", code: "repository_identity_mismatch" }, + 409 + ); + } + body.owner = repository.repoOwner; + body.name = repository.repoName; + } + try { + const grant = await new TeamRepositoryGrantStore(ctx.db).add(team.id, body, { + actorUserId: viewer(ctx).userId, + requestId: ctx.request_id, + }); + return json({ grant }); + } catch (cause) { + return mutationError(cause); + } +} + +async function deleteRepositoryGrant( + _request: Request, + _env: Env, + params: { id: string; grantId: string }, + ctx: RequestContext +) { + if (admittedTeam(ctx).archivedAt !== null) { + return json({ error: "Team is not active", code: "team_not_active" }, 409); + } + const deleted = await new TeamRepositoryGrantStore(ctx.db).remove( + admittedTeam(ctx).id, + params.grantId, + { actorUserId: viewer(ctx).userId, requestId: ctx.request_id } + ); + return deleted ? new Response(null, { status: 204 }) : error("Repository grant not found", 404); +} + export const teamRoutes = new Hono(); -const policy = (authorization: ReturnType) => +const policy = (authorization: RouteAuthorization) => admit({ ...SCM_AGNOSTIC_USER_OR_SERVICE_ROUTE, ...PRIVATE, authorization }); const read = policy(requireTeam("read")); const manage = policy(requireTeam("canEditMetadata")); @@ -486,3 +519,15 @@ teamRoutes.get( }), (c) => dispatch(c, teamSessions) ); +teamRoutes.get( + "/teams/:id/repository-grants", + policy({ ...requireTeam("member"), auditAllowed: false }), + (c) => dispatch(c, repositoryGrants) +); +const repositoriesManage = policy(requireTeam("canManageRepositories")); +teamRoutes.put("/teams/:id/repository-grants", repositoriesManage, (c) => + dispatch(c, putRepositoryGrant) +); +teamRoutes.delete("/teams/:id/repository-grants/:grantId", repositoriesManage, (c) => + dispatch(c, deleteRepositoryGrant) +); diff --git a/packages/control-plane/src/routes/workspace-repository-authorization.test.ts b/packages/control-plane/src/routes/workspace-repository-authorization.test.ts new file mode 100644 index 0000000000..0540697a3f --- /dev/null +++ b/packages/control-plane/src/routes/workspace-repository-authorization.test.ts @@ -0,0 +1,362 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BUILT_IN_ROLE_REGISTRY } from "@open-inspect/shared/rbac"; +import { TeamMembershipStore } from "../db/team-memberships"; +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import { TeamStore } from "../db/teams"; +import { createRequestMetrics } from "../db/instrumented-sql-database"; +import { createTestEnv, TEST_BACKGROUND_TASK_CONTEXT } from "../router.test-support"; +import type { RequestContext } from "./shared"; +import { + authorizeTeamRepositories, + authorizeWorkspaceRepositories, +} from "./workspace-repository-authorization"; + +const repository = { owner: "acme", name: "repo", repoId: 123 }; + +function context(): RequestContext { + return { + db: createTestEnv().DB, + metrics: createRequestMetrics(), + request_id: "request-1", + trace_id: "trace-1", + executionCtx: TEST_BACKGROUND_TASK_CONTEXT, + principal: { kind: "user", userId: "user-1" }, + authorization: { + userId: "user-1", + suspendedAt: null, + role: { id: "custom-role", key: null, name: "Custom" }, + permissions: [], + }, + teamsEnforcementMode: "on", + }; +} + +describe("workspace repository grants", () => { + beforeEach(() => { + vi.spyOn(TeamStore.prototype, "isActive").mockResolvedValue(true); + vi.spyOn(TeamMembershipStore.prototype, "listForUser").mockResolvedValue( + new Map([["team-other", "member"]]) + ); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listTeamsForRepository").mockResolvedValue([ + "team-granted", + ]); + }); + afterEach(() => vi.restoreAllMocks()); + + it.each([null, 0, -1, Number.NaN, 1.5, Number.MAX_SAFE_INTEGER + 1])( + "rejects invalid numeric repository IDs (%s)", + async (repoId) => { + expect( + ( + await authorizeWorkspaceRepositories(context(), { + repositories: [{ ...repository, repoId }], + }) + )?.status + ).toBe(403); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).not.toHaveBeenCalled(); + } + ); + + it.each(["off", "shadow", "on"] as const)( + "preserves unowned repositories in %s mode, even for callers on no team", + async (mode) => { + const ctx = context(); + ctx.teamsEnforcementMode = mode; + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue(new Map()); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([]); + expect(await authorizeWorkspaceRepositories(ctx, { repositories: [repository] })).toBeNull(); + expect( + await authorizeWorkspaceRepositories(ctx, { repositories: [repository], requireLead: true }) + ).toBeNull(); + } + ); + + it.each(["off", "shadow", "on"] as const)( + "denies team-owned repositories to nonmembers in %s mode", + async (mode) => { + const ctx = context(); + ctx.teamsEnforcementMode = mode; + expect( + (await authorizeWorkspaceRepositories(ctx, { repositories: [repository] }))?.status + ).toBe(403); + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue(new Map()); + expect( + (await authorizeWorkspaceRepositories(ctx, { repositories: [repository] }))?.status + ).toBe(403); + } + ); + + it("accepts membership in any granting team rather than choosing a home team", async () => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["team-member", "member"]]) + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "team-other", + "team-member", + ]); + expect( + await authorizeWorkspaceRepositories(context(), { repositories: [repository] }) + ).toBeNull(); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).toHaveBeenCalledWith(123); + }); + + it.each(["off", "shadow", "on"] as const)( + "refuses retained archived memberships in %s mode without making the repository unowned", + async (mode) => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["team-granted", "lead"]]) + ); + vi.mocked(TeamStore.prototype.isActive).mockResolvedValue(false); + const ctx = context(); + ctx.teamsEnforcementMode = mode; + expect( + (await authorizeWorkspaceRepositories(ctx, { repositories: [repository] }))?.status + ).toBe(403); + expect( + ( + await authorizeWorkspaceRepositories(ctx, { + repositories: [repository], + requireLead: true, + }) + )?.status + ).toBe(403); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).toHaveBeenCalledWith(123); + } + ); + + it("accepts an active granting team but not an archived lead", async () => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([ + ["team-archived", "lead"], + ["team-active", "member"], + ]) + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "team-archived", + "team-active", + ]); + vi.mocked(TeamStore.prototype.isActive).mockImplementation( + async (teamId) => teamId === "team-active" + ); + expect( + await authorizeWorkspaceRepositories(context(), { repositories: [repository] }) + ).toBeNull(); + expect( + ( + await authorizeWorkspaceRepositories(context(), { + repositories: [repository], + requireLead: true, + }) + )?.status + ).toBe(403); + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["team-active", "lead"]]) + ); + expect( + await authorizeWorkspaceRepositories(context(), { + repositories: [repository], + requireLead: true, + }) + ).toBeNull(); + }); + + it("allows grants from different caller teams without adding repositories.use", async () => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([ + ["team-1", "member"], + ["team-2", "lead"], + ]) + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockImplementation( + async (repoId) => [repoId === 123 ? "team-1" : "team-2"] + ); + expect( + await authorizeWorkspaceRepositories(context(), { + repositories: [repository, { owner: "acme", name: "other", repoId: 456 }], + }) + ).toBeNull(); + }); + + it("does not need an enforcement mode to apply ownership checks", async () => { + const ctx = context(); + delete ctx.teamsEnforcementMode; + expect( + (await authorizeWorkspaceRepositories(ctx, { repositories: [repository] }))?.status + ).toBe(403); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([]); + expect(await authorizeWorkspaceRepositories(ctx, { repositories: [repository] })).toBeNull(); + }); + + it.each(["member", "lead"] as const)( + "permits a granting team's %s and limits secrets to leads", + async (role) => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["team-granted", role]]) + ); + expect( + await authorizeWorkspaceRepositories(context(), { repositories: [repository] }) + ).toBeNull(); + expect( + ( + await authorizeWorkspaceRepositories(context(), { + repositories: [repository], + requireLead: true, + }) + )?.status ?? null + ).toBe(role === "lead" ? null : 403); + } + ); + + it.each(["owner", "administrator"] as const)( + "allows built-in %s access to every workspace repository surface", + async (key) => { + const ctx = context(); + if (!ctx.authorization) throw new Error("Expected authorization"); + ctx.authorization.role = { ...BUILT_IN_ROLE_REGISTRY[key], name: key }; + expect( + await authorizeWorkspaceRepositories(ctx, { + repositories: [repository], + requireLead: true, + }) + ).toBeNull(); + expect(await authorizeWorkspaceRepositories(ctx, { repositories: [repository] })).toBeNull(); + } + ); + + it("does not derive privileged access from a custom role's display name", async () => { + const ctx = context(); + if (!ctx.authorization) throw new Error("Expected authorization"); + ctx.authorization.role.name = "Owner"; + expect( + (await authorizeWorkspaceRepositories(ctx, { repositories: [repository] }))?.status + ).toBe(403); + }); + + it("fails closed without an admitted actor", async () => { + const ctx = context(); + delete ctx.authorization; + expect( + (await authorizeWorkspaceRepositories(ctx, { repositories: [repository] }))?.status + ).toBe(503); + }); +}); + +describe("explicit team repository grants", () => { + beforeEach(() => { + vi.spyOn(TeamStore.prototype, "isActive").mockResolvedValue(true); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([]); + }); + afterEach(() => vi.restoreAllMocks()); + + it("does not substitute the caller's other teams for the persisted owner", async () => { + const response = await authorizeTeamRepositories(context(), { + teamId: "owner-team", + repositories: [repository], + }); + expect(response?.status).toBe(409); + await expect(response?.json()).resolves.toEqual({ + error: "Target team lacks repository grant", + code: "target_team_missing_grant", + repository: "acme/repo", + }); + expect(TeamStore.prototype.isActive).toHaveBeenCalledWith("owner-team"); + expect(TeamRepositoryGrantStore.prototype.listForTeam).toHaveBeenCalledWith("owner-team"); + }); + + it("denies an archived owner even if it has installation access", async () => { + vi.mocked(TeamStore.prototype.isActive).mockResolvedValue(false); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "installation", repo_external_id: null }, + ]); + expect( + ( + await authorizeTeamRepositories(context(), { + teamId: "owner-team", + repositories: [repository], + }) + )?.status + ).toBe(403); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + }); + + it("preserves a workspace-owned target", async () => { + expect( + await authorizeTeamRepositories(context(), { + teamId: null, + repositories: [repository], + }) + ).toBeNull(); + expect(TeamStore.prototype.isActive).not.toHaveBeenCalled(); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + }); + + it("allows a granted active owner without requiring a new use permission", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 123 }, + ]); + expect( + await authorizeTeamRepositories(context(), { + teamId: "owner-team", + repositories: [repository], + }) + ).toBeNull(); + }); + + it("allows a null repository ID only with an installation grant", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "installation", repo_external_id: null }, + ]); + + expect( + await authorizeTeamRepositories(context(), { + teamId: "owner-team", + repositories: [{ ...repository, repoId: null }], + }) + ).toBeNull(); + }); + + it("denies a null repository ID with only repository grants", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 123 }, + ]); + + const response = await authorizeTeamRepositories(context(), { + teamId: "owner-team", + repositories: [{ ...repository, repoId: null }], + }); + + expect(response?.status).toBe(409); + await expect(response?.json()).resolves.toEqual({ + error: "Target team lacks repository grant", + code: "target_team_missing_grant", + repository: "acme/repo", + }); + }); + + it("names the first uncovered repository while preserving readonly target order", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 123 }, + ]); + const repositories = [ + repository, + { owner: "group/subgroup", name: "api", repoId: 456 }, + { owner: "acme", name: "other", repoId: 789 }, + ] as const; + + const response = await authorizeTeamRepositories(context(), { + teamId: "owner-team", + repositories, + }); + + expect(response?.status).toBe(409); + await expect(response?.json()).resolves.toMatchObject({ repository: "group/subgroup/api" }); + }); + + it("checks team activity but needs no grants for an empty repository set", async () => { + expect( + await authorizeTeamRepositories(context(), { teamId: "owner-team", repositories: [] }) + ).toBeNull(); + expect(TeamStore.prototype.isActive).toHaveBeenCalledWith("owner-team"); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/control-plane/src/routes/workspace-repository-authorization.ts b/packages/control-plane/src/routes/workspace-repository-authorization.ts new file mode 100644 index 0000000000..2fe990ad4b --- /dev/null +++ b/packages/control-plane/src/routes/workspace-repository-authorization.ts @@ -0,0 +1,94 @@ +import { isWorkspaceAdmin } from "@open-inspect/shared/rbac"; +import { TeamMembershipStore } from "../db/team-memberships"; +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import { TeamStore } from "../db/teams"; +import { json, type RequestContext } from "./shared"; +import { missingTeamRepository } from "./session-team-grants"; + +export interface RepositoryAuthorizationTarget { + owner: string; + name: string; + repoId: number | null; +} + +function deniedRepository(repository: RepositoryAuthorizationTarget): Response { + return json( + { + error: "Repository grant required", + code: "repository_grant_required", + reason_code: "repository_grant_required", + repository: `${repository.owner}/${repository.name}`, + }, + 403 + ); +} + +/** Grant-only check; callers retain their existing permission and membership admission. */ +export async function authorizeTeamRepositories( + ctx: RequestContext, + target: { teamId: string | null; repositories: readonly RepositoryAuthorizationTarget[] } +): Promise { + if (target.teamId === null) return null; + if (!(await new TeamStore(ctx.db).isActive(target.teamId))) { + return json({ error: "Team is not active", code: "team_not_active" }, 403); + } + const missing = await missingTeamRepository( + ctx.db, + target.teamId, + target.repositories.map((repository) => ({ + repoOwner: repository.owner, + repoName: repository.name, + repoId: repository.repoId, + })) + ); + if (!missing) return null; + return json( + { + error: "Target team lacks repository grant", + code: "target_team_missing_grant", + repository: `${missing.repoOwner}/${missing.repoName}`, + }, + 409 + ); +} + +/** Unowned repositories stay workspace-level; any granting team may authorize its members. */ +export async function authorizeWorkspaceRepositories( + ctx: RequestContext, + target: { + repositories: readonly RepositoryAuthorizationTarget[]; + requireLead?: boolean; + } +): Promise { + if (target.repositories.length === 0) return null; + const authorization = ctx.authorization; + if (!authorization) { + return json({ error: "Authorization unavailable", code: "authorization_unavailable" }, 503); + } + const roleKey = authorization.role.key; + if (isWorkspaceAdmin(roleKey)) return null; + + const store = new TeamRepositoryGrantStore(ctx.db); + const teams = new TeamStore(ctx.db); + for (const repository of target.repositories) { + const repoId = repository.repoId; + if (repoId === null || !Number.isSafeInteger(repoId) || repoId <= 0) + return deniedRepository(repository); + const owners = await store.listTeamsForRepository(repoId); + if (owners.length === 0) continue; + const memberships = (ctx.sessionMemberships ??= await new TeamMembershipStore( + ctx.db + ).listForUser(authorization.userId)); + let allowed = false; + for (const teamId of owners) { + if (target.requireLead ? memberships.get(teamId) !== "lead" : !memberships.has(teamId)) + continue; + if (await teams.isActive(teamId)) { + allowed = true; + break; + } + } + if (!allowed) return deniedRepository(repository); + } + return null; +} diff --git a/packages/control-plane/src/routing/route-admission.ts b/packages/control-plane/src/routing/route-admission.ts index 31f5868156..133fee8a7e 100644 --- a/packages/control-plane/src/routing/route-admission.ts +++ b/packages/control-plane/src/routing/route-admission.ts @@ -1,10 +1,6 @@ /** Framework-neutral authentication and authorization for a matched route. */ -import { - SCOPED_PERMISSION_PAIRS, - resolveScopedPermission, - type PermissionId, -} from "@open-inspect/shared/rbac"; +import { isWorkspaceAdmin, type PermissionId } from "@open-inspect/shared/rbac"; import { authenticate, isAuthError } from "../auth/authenticate"; import { canonicalUserIdOf, @@ -13,6 +9,10 @@ import { type AccessTokenWrites, type Principal, } from "../auth/principal"; +import { + evaluateOwnedResourceAdmission, + ownedResourceAdmissionResponse, +} from "../authorization/owned-resource-admission"; import type { AuthorizationDecisionRequirement, RouteAuthorizationDecision, @@ -21,7 +21,6 @@ import { AuthorizationError, AuthorizationService } from "../authorization/servi import { serviceAllowsPermission } from "../authorization/service-permissions"; import { evaluateSessionAdmission, viewerFromContext } from "../authorization/session-admission"; import { legacyPermissionForAction } from "../authorization/teams-enforcement"; -import { AutomationStore } from "../db/automation-store"; import { PersonalAccessTokenStore } from "../db/personal-access-tokens"; import { TeamStore } from "../db/teams"; import { TeamMembershipStore } from "../db/team-memberships"; @@ -375,7 +374,11 @@ function enforceStaticServicePermissionCeiling( ? requirement.permission : requirement.kind === "session" ? legacyPermissionForAction(requirement.action) - : null; + : requirement.kind === "environment" + ? (`environments.${requirement.need}` as const) + : requirement.kind === "automation" && requirement.operation === "read" + ? "automations.read" + : null; if (permission && !serviceAllowsPermission(principal.service, permission)) { return authorizationDenial( json({ error: "Forbidden", code: "service_capability_required" }, 403), @@ -562,57 +565,33 @@ async function enforcePermissionRequirement( ); } -async function enforceAutomationRequirement( - requirement: Extract, +async function enforceOwnedResourceRequirement( + requirement: Extract, params: RouteParams, ctx: RequestContext, evidence: AuthorizationEvidence ): Promise { - const principal = ctx.principal; - if (!isSelfActingPrincipal(principal)) { - // Ownership is defined for a principal that is a canonical user: a browser - // user, or an access token, which is its owner. Service policy normally - // rejects bots earlier; this keeps a future `requireAll` composition from - // skipping the ownership check. Admitting a token here decides only whose - // automations it owns — whether it may write to one is the route's own - // `accessTokenWrites` declaration, which most automation routes withhold. - return authorizationDenial( - json({ error: "Forbidden", code: "service_capability_required" }, 403), - evidence, - requirement, - "service_capability_required", - "Forbidden" - ); - } - const automationId = params[requirement.automationIdParam]; - if (!automationId) return { response: json({ error: "Invalid automation route" }, 400) }; - try { - const authorization = ctx.authorization; - if (!authorization) throw new Error("Missing request authorization"); - const store = new AutomationStore(ctx.db); - const storedAutomation = await store.getById(automationId); - if (!storedAutomation) return { response: error("Automation not found", 404) }; - const automation = await store.resolveCanonicalOwner(storedAutomation); - - const permissionStem = `automations.${requirement.operation}` as const; - const pair = SCOPED_PERMISSION_PAIRS[permissionStem]; - const isOwner = automation.user_id === principal.userId; - const scope = resolveScopedPermission(permissionStem, authorization.permissions); - if (!scope || (scope === "own" && !isOwner)) { + const result = await evaluateOwnedResourceAdmission(requirement, params, ctx); + // Denials keep the loaded environment too, so the audit attributes its owner team. + if (result.kind !== "error" && "admission" in result && result.admission) { + ctx.environmentAdmission = result.admission; + } + if (result.kind === "error") { + return { response: ownedResourceAdmissionResponse(result) }; + } + if (result.kind === "denied") { return authorizationDenial( - json({ error: "Forbidden", code: "permission_required", permission: pair.own }, 403), + ownedResourceAdmissionResponse(result), evidence, requirement, - "permission_required", - "Forbidden", - pair.own + result.reasonCode, + result.reason, + result.failedPermission ); } - evidence.requirements.push(requirement); - evidence.effectivePermissions.push(pair[scope]); - ctx.automationAdmission = { automation }; + if (result.effectivePermission) evidence.effectivePermissions.push(result.effectivePermission); return null; } catch { return authorizationUnavailable(); @@ -640,14 +619,21 @@ async function enforceTeamRequirement( if (!teamId) return { response: json({ error: "Invalid team route" }, 400) }; try { const team = await new TeamStore(ctx.db).getById(teamId); - if (!team) return { response: error("Team not found", 404) }; + if (!team) + return authorizationDenial( + error("Team not found", 404), + evidence, + requirement, + "team_not_visible", + "Team not found" + ); const memberships = new TeamMembershipStore(ctx.db); const viewer = viewerFromContext( ctx, (ctx.sessionMemberships ??= await memberships.listForUser(ctx.principal.userId)) ); if (viewer.kind !== "user") throw new Error("Missing team viewer"); - const isAdmin = viewer.roleKey === "owner" || viewer.roleKey === "administrator"; + const isAdmin = isWorkspaceAdmin(viewer.roleKey); const isMember = isAdmin || viewer.memberships.has(teamId); if ( !isMember && @@ -655,7 +641,13 @@ async function enforceTeamRequirement( requirement.need === "removeMember" || (requirement.need === "read" && team.archivedAt !== null)) ) - return { response: error("Team not found", 404) }; + return authorizationDenial( + error("Team not found", 404), + evidence, + requirement, + "team_not_visible", + "Team not found" + ); const access = resolveTeamAccess( { userId: viewer.userId, @@ -835,7 +827,8 @@ async function enforceRouteAuthorization( failure = await enforcePermissionRequirement(requirement, ctx, evidence); break; case "automation": - failure = await enforceAutomationRequirement(requirement, params, ctx, evidence); + case "environment": + failure = await enforceOwnedResourceRequirement(requirement, params, ctx, evidence); break; case "team": failure = await enforceTeamRequirement(requirement, params, ctx, evidence); diff --git a/packages/control-plane/src/sandbox/client.test.ts b/packages/control-plane/src/sandbox/client.test.ts index bf17806389..0535640d54 100644 --- a/packages/control-plane/src/sandbox/client.test.ts +++ b/packages/control-plane/src/sandbox/client.test.ts @@ -8,6 +8,9 @@ import { createModalClient, } from "./client"; import { RequestDeadlineError } from "./request-deadline"; +import { scmCloneIdentity } from "./sandbox-env"; + +const GITHUB_IDENTITY = scmCloneIdentity("github"); function rejectWhenAborted(signal: AbortSignal): Promise { if (signal.aborted) return Promise.reject(signal.reason); @@ -186,6 +189,7 @@ describe("ModalClient", () => { const client = createModalClient("secret", "acme"); await expect( client.createSandbox({ + scmIdentity: GITHUB_IDENTITY, sessionId: "session", sandboxId: "generation", sandboxBackend: "modal-vm", @@ -204,6 +208,7 @@ describe("ModalClient", () => { ); await expect( createModalClient("secret", "acme").restoreSandbox({ + scmIdentity: GITHUB_IDENTITY, snapshotImageId: "image", sessionId: "session", sandboxId: "generation", @@ -224,6 +229,7 @@ describe("ModalClient", () => { Response.json({ detail: "docker_not_available" }, { status: 501 }) ); const created = createModalClient("secret", "acme").createSandbox({ + scmIdentity: GITHUB_IDENTITY, sessionId: "session", sandboxId: "generation", sandboxBackend: "modal-vm", @@ -247,6 +253,7 @@ describe("ModalClient", () => { }); const request = createModalClient("secret", "acme").createImageBuildSandbox({ + scmIdentity: GITHUB_IDENTITY, scopeKind: "repo", scopeId: "acme/repo", buildId: "imgb-1", @@ -352,6 +359,7 @@ describe("ModalClient", () => { endpoint: "create sandbox", call: (client: ReturnType) => client.createSandbox({ + scmIdentity: GITHUB_IDENTITY, sessionId: "session-123", repoOwner: null, repoName: null, @@ -364,6 +372,7 @@ describe("ModalClient", () => { endpoint: "restore sandbox", call: (client: ReturnType) => client.restoreSandbox({ + scmIdentity: GITHUB_IDENTITY, snapshotImageId: "img-1", sessionId: "session-123", sandboxId: "sandbox-456", @@ -388,6 +397,7 @@ describe("ModalClient", () => { endpoint: "create image-build sandbox", call: (client: ReturnType) => client.createImageBuildSandbox({ + scmIdentity: GITHUB_IDENTITY, scopeKind: "repo", scopeId: "acme/repo", buildId: "imgb-1", @@ -420,7 +430,7 @@ describe("ModalClient", () => { ); }); - it("routes the restore session_config through buildSessionConfig (carries mcp_servers)", async () => { + it("sends restore SCM identity without a token and routes session_config through buildSessionConfig", async () => { const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response(JSON.stringify({ success: true, data: { sandbox_id: "sb-1" } }), { status: 200, @@ -441,9 +451,15 @@ describe("ModalClient", () => { provider: "anthropic", model: "anthropic/claude-sonnet-4-5", mcpServers: [{ id: "mcp-1", name: "Tool", type: "local", enabled: true }], + scmIdentity: scmCloneIdentity("github"), }); const body = JSON.parse((fetchMock.mock.calls[0]?.[1] as RequestInit).body as string); + expect(body).toMatchObject({ + clone_host: "github.com", + clone_username: "x-access-token", + }); + expect(body).not.toHaveProperty("clone_token"); expect(body.session_config).toEqual({ session_id: "session-123", harness: "opencode", @@ -456,6 +472,52 @@ describe("ModalClient", () => { }); }); + it.each(["base", "prebuilt", "restore"] as const)( + "sends the same GitLab identity without a token on %s launch", + async (source) => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + data: { sandbox_id: "sb-1", status: "spawning", created_at: 1 }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ) + ); + const client = createModalClient("secret", "acme", "prod-web"); + const request = { + sessionId: "session-123", + sandboxId: "sandbox-456", + sandboxAuthToken: "auth-token", + controlPlaneUrl: "https://control-plane.test", + repoOwner: "group/subgroup", + repoName: "repo", + harness: "opencode" as const, + provider: "anthropic", + model: "claude-sonnet-4-5", + scmIdentity: scmCloneIdentity("gitlab"), + }; + if (source === "restore") { + await client.restoreSandbox({ ...request, snapshotImageId: "img-1" }); + } else { + await client.createSandbox({ + ...request, + prebuiltImageId: source === "prebuilt" ? "img-1" : undefined, + }); + } + + const body = JSON.parse((fetchMock.mock.calls[0]?.[1] as RequestInit).body as string); + expect(body).toMatchObject({ + clone_host: "gitlab.com", + clone_username: "oauth2", + }); + expect(body).not.toHaveProperty("clone_token"); + } + ); + it("asks Modal's create handler for early bridge connect by SessionConfig field name", async () => { const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response( @@ -469,6 +531,7 @@ describe("ModalClient", () => { const client = createModalClient("secret", "acme", "prod-web"); await client.createSandbox({ + scmIdentity: GITHUB_IDENTITY, sessionId: "session-123", sandboxId: "sandbox-456", repoOwner: "testowner", @@ -497,6 +560,7 @@ describe("ModalClient", () => { const client = createModalClient("secret", "acme", "prod-web"); await client.createSandbox({ + scmIdentity: GITHUB_IDENTITY, sessionId: "session-123", sandboxId: "sandbox-456", repoOwner: "testowner", @@ -530,6 +594,7 @@ describe("ModalClient", () => { const client = createModalClient("secret", "acme", "prod-web"); await client.createSandbox({ + scmIdentity: GITHUB_IDENTITY, sessionId: "session-123", repoOwner: "testowner", repoName: "testrepo", @@ -551,6 +616,7 @@ describe("ModalClient", () => { .mockResolvedValueOnce(Response.json({ success: true, data: { sandbox_id: "sb-2" } })); const client = createModalClient("secret", "acme", "prod-web"); await client.createSandbox({ + scmIdentity: GITHUB_IDENTITY, sessionId: "session-1", repoOwner: null, repoName: null, @@ -560,6 +626,7 @@ describe("ModalClient", () => { launchDeadlineAtMs: 123456, }); await client.restoreSandbox({ + scmIdentity: GITHUB_IDENTITY, snapshotImageId: "im-1", sessionId: "session-1", sandboxId: "sandbox-1", @@ -602,6 +669,7 @@ describe("ModalClient", () => { const client = createModalClient("secret", "acme", "prod-web"); await expect( client.createSandbox({ + scmIdentity: GITHUB_IDENTITY, sessionId: "session-123", repoOwner: "testowner", repoName: "testrepo", @@ -646,6 +714,7 @@ describe("ModalClient", () => { const client = createModalClient("secret", "acme", "prod-web"); const result = await client.createSandbox({ + scmIdentity: GITHUB_IDENTITY, sessionId: "session-123", repoOwner: "testowner", repoName: "testrepo", @@ -681,6 +750,7 @@ describe("ModalClient", () => { const client = createModalClient("secret", "acme", "prod-web"); await expect( client.createSandbox({ + scmIdentity: GITHUB_IDENTITY, sessionId: "session-123", repoOwner: "testowner", repoName: "testrepo", @@ -701,6 +771,7 @@ describe("ModalClient", () => { const client = createModalClient("secret", "acme", "prod-web"); await client.restoreSandbox({ + scmIdentity: GITHUB_IDENTITY, snapshotImageId: "img-1", sessionId: "session-123", sandboxId: "sandbox-456", @@ -740,6 +811,7 @@ describe("ModalClient", () => { const client = createModalClient("secret", "acme", "prod-web"); await expect( client.restoreSandbox({ + scmIdentity: GITHUB_IDENTITY, snapshotImageId: "img-1", sessionId: "session-123", sandboxId: "sandbox-456", @@ -778,6 +850,7 @@ describe("ModalClient", () => { const client = createModalClient("secret", "acme", "prod-web"); await expect( client.restoreSandbox({ + scmIdentity: GITHUB_IDENTITY, snapshotImageId: "img-1", sessionId: "session-123", sandboxId: "sandbox-456", @@ -815,6 +888,7 @@ describe("ModalClient", () => { const client = createModalClient("secret", "acme", "prod-web"); await client.createSandbox({ + scmIdentity: GITHUB_IDENTITY, sessionId: "session-123", repoOwner: null, repoName: null, @@ -824,6 +898,7 @@ describe("ModalClient", () => { vncEnabled: true, }); await client.restoreSandbox({ + scmIdentity: GITHUB_IDENTITY, snapshotImageId: "img-1", sessionId: "session-123", sandboxId: "sandbox-456", @@ -940,8 +1015,7 @@ describe("ModalClient", () => { buildId: "imgb-1", repositories: [{ repoOwner: "acme", repoName: "repo", baseBranch: "develop" }], cloneToken: "clone-token", - cloneHost: "gitlab.com", - cloneUsername: "oauth2", + scmIdentity: scmCloneIdentity("gitlab"), callbackUrl: "https://worker.test/image-builds/build-complete", failureCallbackUrl: "https://worker.test/image-builds/build-failed", buildExecutionTimeoutSeconds: 1800, @@ -1020,8 +1094,7 @@ describe("ModalClient", () => { buildId: "imgb-1", repositories: [{ repoOwner: "acme", repoName: "repo", baseBranch: "develop" }], cloneToken: "clone-token", - cloneHost: "github.com", - cloneUsername: "x-access-token", + scmIdentity: GITHUB_IDENTITY, callbackUrl: "https://cp.test/image-builds/build-complete", failureCallbackUrl: "https://cp.test/image-builds/build-failed", buildExecutionTimeoutSeconds: 1800, diff --git a/packages/control-plane/src/sandbox/client.ts b/packages/control-plane/src/sandbox/client.ts index e450bbe83b..1440e75888 100644 --- a/packages/control-plane/src/sandbox/client.ts +++ b/packages/control-plane/src/sandbox/client.ts @@ -12,7 +12,11 @@ import type { McpServerConfig, SandboxSettings } from "@open-inspect/shared/type import { z } from "zod"; import { createLogger } from "../logger"; import type { CorrelationContext } from "../logger"; -import { buildSessionConfig, toRepositoryConfigPayload } from "./sandbox-env"; +import { + buildSessionConfig, + toRepositoryConfigPayload, + type ScmCloneIdentity, +} from "./sandbox-env"; import type { SessionRepositoryInfo } from "./provider"; import { parsePendingVmReference } from "./providers/pending-vm-reference"; import { withRequestDeadline } from "./request-deadline"; @@ -160,6 +164,7 @@ export function buildModalSandboxDashboardUrl(params: { } export interface CreateSandboxRequest { + scmIdentity: ScmCloneIdentity; sandboxBackend?: ModalBackend; retireSandboxId?: string | null; launchDeadlineAtMs?: number; @@ -202,6 +207,7 @@ export interface CreateSandboxResponse { } export interface RestoreSandboxRequest { + scmIdentity: ScmCloneIdentity; sandboxBackend?: ModalBackend; retireSandboxId?: string | null; launchDeadlineAtMs?: number; @@ -283,9 +289,8 @@ export interface CreateImageBuildSandboxRequest { buildId: string; /** Repositories in position order ([0] = primary), cloned at their base branches. */ repositories: Array<{ repoOwner: string; repoName: string; baseBranch: string }>; + scmIdentity: ScmCloneIdentity; cloneToken?: string; - cloneHost?: string; - cloneUsername?: string; callbackUrl: string; failureCallbackUrl: string; userEnvVars?: Record; @@ -506,6 +511,8 @@ export class ModalClient { repo_name: request.repoName, control_plane_url: request.controlPlaneUrl, sandbox_auth_token: request.sandboxAuthToken, + clone_host: request.scmIdentity.host, + clone_username: request.scmIdentity.cloneUsername, agent_session_id: request.agentSessionId || null, harness: request.harness, provider: request.provider || "anthropic", @@ -585,6 +592,8 @@ export class ModalClient { MODAL_SANDBOX_START_REQUEST_DEADLINE_MS, { snapshot_image_id: request.snapshotImageId, + clone_host: request.scmIdentity.host, + clone_username: request.scmIdentity.cloneUsername, session_config: buildSessionConfig(request), sandbox_id: request.sandboxId, control_plane_url: request.controlPlaneUrl, @@ -790,8 +799,8 @@ export class ModalClient { build_id: request.buildId, repositories: request.repositories.map(toRepositoryConfigPayload), clone_token: request.cloneToken, - clone_host: request.cloneHost, - clone_username: request.cloneUsername, + clone_host: request.scmIdentity.host, + clone_username: request.scmIdentity.cloneUsername, callback_url: request.callbackUrl, failure_callback_url: request.failureCallbackUrl, user_env_vars: request.userEnvVars, diff --git a/packages/control-plane/src/sandbox/lifecycle/alarm-boot-budget-effects.test.ts b/packages/control-plane/src/sandbox/lifecycle/alarm-boot-budget-effects.test.ts index 8b25ace946..abf5cb7fed 100644 --- a/packages/control-plane/src/sandbox/lifecycle/alarm-boot-budget-effects.test.ts +++ b/packages/control-plane/src/sandbox/lifecycle/alarm-boot-budget-effects.test.ts @@ -4,7 +4,10 @@ import type { StopResult } from "../provider"; import { createAlarmFixture, createMockSandbox, createMockProvider } from "./test-helpers"; describe("boot budget alarm effects", () => { - afterEach(() => vi.restoreAllMocks()); + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); it("shuts down before fencing and failing the generation, then publishes the persisted phase error", async () => { const sandbox = createMockSandbox({ @@ -127,6 +130,8 @@ describe("boot budget alarm effects", () => { }); it("holds the termination guard only for provider stop, after publishing and detaching", async () => { + vi.useFakeTimers(); + vi.setSystemTime(10_000_000); const sandbox = createMockSandbox({ status: "connecting", created_at: Date.now() - DEFAULT_LIFECYCLE_CONFIG.bootBudget.timeoutMs, @@ -151,11 +156,34 @@ describe("boot budget alarm effects", () => { sandbox.fenced = 1; }); vi.spyOn(h.storage, "updateSandboxStatus").mockImplementation((status) => { - observations.push(`status:${h.manager.isSpawning()}`); + observations.push(`status:${status}:${h.manager.isSpawning()}`); sandbox.status = status; }); - vi.spyOn(h.broadcaster, "broadcast").mockImplementation(() => { - observations.push(`broadcast:${h.manager.isSpawning()}`); + const countFailure = vi + .mocked(h.storage.incrementCircuitBreakerFailure) + .getMockImplementation()!; + vi.mocked(h.storage.incrementCircuitBreakerFailure).mockImplementation((timestamp) => { + observations.push(`failure:${h.manager.isSpawning()}`); + countFailure(timestamp); + }); + const clearAccess = vi.mocked(h.storage.clearSandboxAccess).getMockImplementation()!; + vi.mocked(h.storage.clearSandboxAccess).mockImplementation((kind) => { + observations.push(`clear:${kind}:${h.manager.isSpawning()}`); + clearAccess(kind); + }); + const clearTunnels = vi.mocked(h.storage.clearSandboxTunnelUrls).getMockImplementation()!; + vi.mocked(h.storage.clearSandboxTunnelUrls).mockImplementation(() => { + observations.push(`clear:tunnels:${h.manager.isSpawning()}`); + clearTunnels(); + }); + const persistError = vi.mocked(h.storage.setLastSpawnError).getMockImplementation()!; + vi.mocked(h.storage.setLastSpawnError).mockImplementation((reason, timestamp) => { + observations.push(`persist:error:${h.manager.isSpawning()}`); + persistError(reason, timestamp); + }); + vi.spyOn(h.broadcaster, "broadcast").mockImplementation((message) => { + observations.push(`broadcast:${message.type}:${h.manager.isSpawning()}`); + h.broadcaster.messages.push(message); }); vi.spyOn(h.wsManager, "detachSandboxWebSocket").mockImplementation(() => { observations.push(`detach:${h.manager.isSpawning()}`); @@ -167,13 +195,26 @@ describe("boot budget alarm effects", () => { expect(observations).toEqual([ "send:false", "fence:false", - "status:false", - "broadcast:false", - "broadcast:false", - "broadcast:false", + "status:failed:false", + "failure:false", + "clear:codeServer:false", + "clear:vnc:false", + "clear:ttyd:false", + "clear:tunnels:false", + "broadcast:sandbox_access_changed:false", + "broadcast:sandbox_status:false", + "persist:error:false", + "broadcast:sandbox_error:false", "detach:false", "stop:true", ]); + expect(h.broadcaster.messages).toEqual([ + { type: "sandbox_access_changed" }, + { type: "sandbox_status", status: "failed" }, + { type: "sandbox_error", error: sandbox.last_spawn_error }, + ]); + expect(sandbox.spawn_failure_count).toBe(1); + expect(sandbox.last_spawn_error_at).toBe(Date.now()); expect(h.manager.isSpawning()).toBe(false); }); @@ -185,7 +226,7 @@ describe("boot budget alarm effects", () => { created_at: Date.now() - DEFAULT_LIFECYCLE_CONFIG.bootBudget.timeoutMs, code_server_url: "https://code.test", }); - const stopSandbox = vi.fn(async () => { + const stopSandbox = vi.fn(async (): Promise => { if (failure === "rejected") throw new Error("provider stop unavailable"); return { success: false, error: "provider stop unavailable" }; }); @@ -223,6 +264,13 @@ describe("boot budget alarm effects", () => { 1000, "Boot budget exceeded" ); + + stopSandbox.mockResolvedValue({ success: true }); + await h.manager.spawnSandbox(); + expect(h.storage.updateSandboxForSpawn).toHaveBeenCalledOnce(); + expect(h.provider.createSandbox).toHaveBeenCalledOnce(); + expect(sandbox.status).toBe("connecting"); + expect(h.manager.isSpawning()).toBe(false); } ); diff --git a/packages/control-plane/src/sandbox/lifecycle/alarm-effects.test.ts b/packages/control-plane/src/sandbox/lifecycle/alarm-effects.test.ts index 5f7e1498fb..0086fa8c3e 100644 --- a/packages/control-plane/src/sandbox/lifecycle/alarm-effects.test.ts +++ b/packages/control-plane/src/sandbox/lifecycle/alarm-effects.test.ts @@ -169,6 +169,343 @@ describe("cross-path alarm effects", () => { } ); + describe.each(["heartbeat", "inactivity"] as const)("%s continuation", (trigger) => { + it.each([ + { resumable: false, change: "id" }, + { resumable: false, change: "timestamp" }, + { resumable: false, change: "metadata" }, + { resumable: true, change: "id" }, + { resumable: true, change: "timestamp" }, + { resumable: true, change: "metadata" }, + ] as const)( + "revalidates $change changes during stop using fresh rows (resumable=$resumable)", + async ({ resumable, change }) => { + vi.useFakeTimers(); + vi.setSystemTime(10_000_000); + const now = Date.now(); + const sandbox = createMockSandbox({ + last_heartbeat: trigger === "heartbeat" ? now - 100_000 : now, + last_activity: now - DEFAULT_LIFECYCLE_CONFIG.inactivity.timeoutMs - 1, + }); + const providerObjectId = sandbox.modal_object_id; + const createdAt = sandbox.created_at; + const initialRow = { ...sandbox }; + let stopStarted!: () => void; + const started = new Promise((resolve) => { + stopStarted = resolve; + }); + let releaseStop!: () => void; + const stopGate = new Promise((resolve) => { + releaseStop = resolve; + }); + const stopSandbox = vi.fn(async () => { + stopStarted(); + await stopGate; + return { success: true }; + }); + const h = createAlarmFixture( + sandbox, + createMockProvider({ + capabilities: { supportsExplicitStop: true, supportsPersistentResume: resumable }, + stopSandbox, + }) + ); + // Repository reads are snapshots; writes only mutate the persisted fixture row. + vi.mocked(h.storage.getSandbox).mockImplementation(() => ({ ...sandbox })); + const pending = h.manager.handleAlarm(); + try { + await started; + const alarmRow = vi.mocked(h.storage.getSandbox).mock.results[0].value; + expect(alarmRow).not.toBe(sandbox); + expect(alarmRow).toEqual(initialRow); + expect(sandbox.status).toBe(trigger === "heartbeat" ? "stale" : "stopped"); + // These paths intentionally don't share the boot-budget termination guard. + expect(h.manager.isSpawning()).toBe(false); + expect(stopSandbox).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + providerObjectId, + generationCreatedAtMs: createdAt, + intent: resumable ? "preserve" : "destroy", + }) + ); + Object.assign(sandbox, { + modal_sandbox_id: change === "id" ? "replacement" : sandbox.modal_sandbox_id, + created_at: change === "timestamp" ? createdAt + 1 : createdAt, + modal_object_id: "updated-provider-object", + status: "connecting", + last_heartbeat: now, + last_activity: now, + code_server_url: "https://code.test/updated", + }); + const updatedRow = { ...sandbox }; + const publications = [...h.broadcaster.messages]; + releaseStop(); + + const continues = change === "metadata"; + await expect(pending).resolves.toBe(continues ? "sandbox_terminated" : "no_action"); + + expect(alarmRow).toEqual(initialRow); + expect(sandbox).toEqual(updatedRow); + expect(h.storage.getSandbox()).not.toBe(alarmRow); + expect(h.broadcaster.messages).toEqual([ + ...publications, + ...(continues && trigger === "inactivity" + ? [ + { + type: "sandbox_warning", + message: resumable + ? "Sandbox stopped due to inactivity" + : "Sandbox stopped due to inactivity, snapshot saved", + }, + ] + : []), + ]); + if (continues) { + expect(h.wsManager.detachSandboxWebSocket).toHaveBeenCalledExactlyOnceWith( + 1000, + trigger === "heartbeat" ? "Heartbeat stale" : "Inactivity timeout" + ); + } else { + expect(h.wsManager.detachSandboxWebSocket).not.toHaveBeenCalled(); + } + expect(h.wsManager.sendToSandbox).toHaveBeenCalledTimes( + !resumable && (trigger === "inactivity" || continues) ? 1 : 0 + ); + expect(h.provider.takeSnapshot).toHaveBeenCalledTimes(resumable ? 0 : 1); + } finally { + releaseStop(); + await pending; + } + } + ); + + it("abandons teardown when checkpoint uncertainty takes ownership", async () => { + vi.useFakeTimers(); + vi.setSystemTime(10_000_000); + const now = Date.now(); + const sandbox = createMockSandbox({ + last_heartbeat: trigger === "heartbeat" ? now - 100_000 : now, + last_activity: now - DEFAULT_LIFECYCLE_CONFIG.inactivity.timeoutMs - 1, + }); + let captureStarted!: () => void; + const started = new Promise((resolve) => { + captureStarted = resolve; + }); + let releaseCapture!: () => void; + const captureGate = new Promise((resolve) => { + releaseCapture = resolve; + }); + const takeSnapshot = vi.fn(async () => { + captureStarted(); + await captureGate; + throw new Error("checkpoint response lost"); + }); + const stopSandbox = vi.fn(async () => ({ success: true })); + const h = createAlarmFixture( + sandbox, + createMockProvider({ + capabilities: { supportsExplicitStop: true }, + takeSnapshot, + stopSandbox, + }) + ); + const pending = h.manager.handleAlarm(); + try { + await started; + expect(h.shutdown.isHolding()).toBe(true); + await expect(h.manager.handleAlarm()).resolves.toBe("no_action"); + await expect(h.manager.terminateFailedSandbox("competing fatal report")).resolves.toBe( + false + ); + await h.manager.terminateUnresponsiveSandbox("stop_send_failed"); + } finally { + releaseCapture(); + await pending; + } + + await expect(pending).resolves.toBe("no_action"); + expect(h.shutdown.isHolding()).toBe(true); + await expect(h.manager.handleAlarm()).resolves.toBe("no_action"); + expect(takeSnapshot).toHaveBeenCalledOnce(); + expect(stopSandbox).not.toHaveBeenCalled(); + expect(h.wsManager.sendToSandbox).not.toHaveBeenCalled(); + expect(h.wsManager.detachSandboxWebSocket).not.toHaveBeenCalled(); + }); + }); + + it("characterizes inherited inactivity retirement and absent-handle retargeting", async () => { + vi.useFakeTimers(); + vi.setSystemTime(10_000_000); + const now = Date.now(); + const sandbox = createMockSandbox({ + modal_object_id: null, + last_heartbeat: now, + last_activity: now - DEFAULT_LIFECYCLE_CONFIG.inactivity.timeoutMs - 1, + }); + const initialRow = { ...sandbox }; + const replacement = createMockSandbox({ + modal_sandbox_id: "sandbox-replacement", + modal_object_id: "modal-obj-replacement", + created_at: now, + last_heartbeat: now, + last_activity: now, + code_server_url: "https://code.test/replacement", + code_server_password: "replacement-code-secret", + vnc_url: "https://vnc.test/replacement", + vnc_password: "replacement-vnc-secret", + ttyd_url: "https://terminal.test/replacement", + ttyd_token: "replacement-terminal-secret", + tunnel_urls: '{"3000":"https://preview.test/replacement"}', + }); + const stopSandbox = vi.fn(async () => ({ success: true })); + const h = createAlarmFixture( + sandbox, + createMockProvider({ + capabilities: { supportsExplicitStop: true, supportsPersistentResume: true }, + stopSandbox, + }) + ); + vi.mocked(h.storage.getSandbox).mockImplementation(() => ({ ...sandbox })); + let shutdownStarted!: () => void; + const started = new Promise((resolve) => { + shutdownStarted = resolve; + }); + let releaseShutdown!: () => void; + const shutdownGate = new Promise((resolve) => { + releaseShutdown = resolve; + }); + const requestShutdown = vi.spyOn(h.shutdown, "requestShutdown").mockImplementation(async () => { + shutdownStarted(); + await shutdownGate; + return "unmanaged"; + }); + const pending = h.manager.handleAlarm(); + try { + await started; + const alarmRow = vi.mocked(h.storage.getSandbox).mock.results[0].value; + expect(alarmRow).not.toBe(sandbox); + expect(alarmRow).toEqual(initialRow); + expect(requestShutdown).toHaveBeenCalledExactlyOnceWith("inactivity_timeout"); + expect(h.storage.updateSandboxStatus).not.toHaveBeenCalled(); + expect(h.broadcaster.messages).toEqual([]); + expect(stopSandbox).not.toHaveBeenCalled(); + Object.assign(sandbox, replacement); + + // Inherited gaps, not safety guarantees: retirement is unscoped and an absent handle falls back. + releaseShutdown(); + await expect(pending).resolves.toBe("no_action"); + + expect(alarmRow).toEqual(initialRow); + expect(h.storage.getSandbox()).not.toBe(alarmRow); + expect(sandbox).toEqual({ + ...replacement, + status: "stopped", + code_server_url: null, + vnc_url: null, + ttyd_url: null, + tunnel_urls: null, + }); + expect(stopSandbox).toHaveBeenCalledExactlyOnceWith({ + providerObjectId: replacement.modal_object_id, + sessionId: "test-session", + reason: "inactivity_timeout", + intent: "preserve", + signal: undefined, + generationCreatedAtMs: initialRow.created_at, + }); + expect(h.broadcaster.messages).toEqual([ + { type: "sandbox_access_changed" }, + { type: "sandbox_status", status: "stopped" }, + ]); + expect(h.provider.takeSnapshot).not.toHaveBeenCalled(); + expect(h.wsManager.sendToSandbox).not.toHaveBeenCalled(); + expect(h.wsManager.detachSandboxWebSocket).not.toHaveBeenCalled(); + } finally { + releaseShutdown(); + await pending; + } + }); + + it("characterizes inherited connect-timeout failure publication on a replacement", async () => { + vi.useFakeTimers(); + vi.setSystemTime(10_000_000); + const now = Date.now(); + const sandbox = createMockSandbox({ + status: "connecting", + created_at: now - DEFAULT_LIFECYCLE_CONFIG.connectingTimeout.timeoutMs - 1, + last_heartbeat: null, + }); + const initialRow = { ...sandbox }; + const replacement = createMockSandbox({ + modal_sandbox_id: "sandbox-replacement", + modal_object_id: "modal-obj-replacement", + created_at: now, + last_heartbeat: now, + last_activity: now, + code_server_url: "https://code.test/replacement", + }); + let stopStarted!: () => void; + const started = new Promise((resolve) => { + stopStarted = resolve; + }); + let releaseStop!: () => void; + const stopGate = new Promise((resolve) => { + releaseStop = resolve; + }); + const stopSandbox = vi.fn(async () => { + stopStarted(); + await stopGate; + return { success: true }; + }); + const h = createAlarmFixture( + sandbox, + createMockProvider({ capabilities: { supportsExplicitStop: true }, stopSandbox }) + ); + vi.mocked(h.storage.getSandbox).mockImplementation(() => ({ ...sandbox })); + vi.mocked(h.storage.getSandboxWithCircuitBreaker).mockImplementation(() => ({ ...sandbox })); + const pending = h.manager.handleAlarm(); + try { + await started; + expect(sandbox).toMatchObject({ + status: "failed", + fenced: 1, + spawn_failure_count: 1, + last_spawn_error: null, + }); + expect(h.broadcaster.messages).toEqual([{ type: "sandbox_access_changed" }]); + expect(stopSandbox).toHaveBeenCalledExactlyOnceWith({ + providerObjectId: initialRow.modal_object_id, + sessionId: "test-session", + reason: "connecting_timeout", + intent: "destroy", + signal: undefined, + generationCreatedAtMs: initialRow.created_at, + }); + Object.assign(sandbox, replacement); + + // The old failure still publishes and persists after replacement; this is inherited behavior. + releaseStop(); + await expect(pending).resolves.toBe("sandbox_failed"); + + const error = + "Sandbox failed to connect within the allowed time. It will be retried on your next message."; + expect(sandbox).toEqual({ + ...replacement, + last_spawn_error: error, + last_spawn_error_at: now, + }); + expect(h.storage.setLastSpawnError).toHaveBeenCalledExactlyOnceWith(error, now); + expect(h.broadcaster.messages).toEqual([ + { type: "sandbox_access_changed" }, + { type: "sandbox_status", status: "failed" }, + { type: "sandbox_error", error }, + ]); + } finally { + releaseStop(); + await pending; + } + }); + it.each(["heartbeat", "budget"] as const)( "continues the failure streak and blocks replacement after a long %s boot", async (trigger) => { diff --git a/packages/control-plane/src/sandbox/lifecycle/alarm-inactivity-effects.test.ts b/packages/control-plane/src/sandbox/lifecycle/alarm-inactivity-effects.test.ts index 02a74e5c25..61697ece2a 100644 --- a/packages/control-plane/src/sandbox/lifecycle/alarm-inactivity-effects.test.ts +++ b/packages/control-plane/src/sandbox/lifecycle/alarm-inactivity-effects.test.ts @@ -54,6 +54,49 @@ describe("inactivity alarm effects", () => { ); }); + it.each(["owned", "held"] as const)( + "waits for shutdown ownership and avoids legacy effects when %s", + async (ownership) => { + const sandbox = createMockSandbox({ + last_activity: Date.now() - DEFAULT_LIFECYCLE_CONFIG.inactivity.timeoutMs - 1, + code_server_url: "https://code.test", + }); + const original = { ...sandbox }; + const h = createAlarmFixture( + sandbox, + createMockProvider({ + capabilities: { supportsExplicitStop: true }, + stopSandbox: vi.fn(async () => ({ success: true })), + }) + ); + let releaseOwnership!: (result: "owned" | "held") => void; + const decision = new Promise<"owned" | "held">((resolve) => { + releaseOwnership = resolve; + }); + vi.spyOn(h.shutdown, "requestShutdown").mockReturnValue(decision); + const pending = h.manager.handleAlarm(); + + try { + expect(h.shutdown.requestShutdown).toHaveBeenCalledExactlyOnceWith("inactivity_timeout"); + expect(sandbox).toEqual(original); + expect(h.broadcaster.messages).toEqual([]); + } finally { + releaseOwnership(ownership); + await pending; + } + + await expect(pending).resolves.toBe("no_action"); + expect(sandbox).toEqual(original); + expect(h.storage.updateSandboxStatus).not.toHaveBeenCalled(); + expect(h.storage.clearSandboxAccess).not.toHaveBeenCalled(); + expect(h.provider.takeSnapshot).not.toHaveBeenCalled(); + expect(h.provider.stopSandbox).not.toHaveBeenCalled(); + expect(h.wsManager.sendToSandbox).not.toHaveBeenCalled(); + expect(h.wsManager.detachSandboxWebSocket).not.toHaveBeenCalled(); + expect(h.broadcaster.messages).toEqual([]); + } + ); + describe.each(["rejected", "unsuccessful"] as const)("%s provider stop", (failure) => { it.each([false, true])("still retires and warns (resumable=%s)", async (resumable) => { const sandbox = createMockSandbox({ diff --git a/packages/control-plane/src/sandbox/lifecycle/manager-shutdown.test.ts b/packages/control-plane/src/sandbox/lifecycle/manager-shutdown.test.ts index 2054cc8ce7..c305d95e0d 100644 --- a/packages/control-plane/src/sandbox/lifecycle/manager-shutdown.test.ts +++ b/packages/control-plane/src/sandbox/lifecycle/manager-shutdown.test.ts @@ -417,7 +417,13 @@ describe("final graceful shutdown lifecycle integration", () => { ); if (failure === "boot budget") row.last_heartbeat = Date.now(); expect(await f.manager.handleShutdownAlarm()).toBe("continue"); - await f.manager.handleAlarm(); + const result = await f.manager.handleAlarm(); + if (failure === "boot budget") { + expect(result).toEqual({ kind: "boot_budget_exceeded", reason: row.last_spawn_error }); + expect(f.sockets.sendToSandbox).not.toHaveBeenCalled(); + expect(f.sockets.detachSandboxWebSocket).not.toHaveBeenCalled(); + expect(f.manager.isSpawning()).toBe(false); + } } // Neither deleted nor fenced: the source is the only copy of the workspace. @@ -431,6 +437,9 @@ describe("final graceful shutdown lifecycle integration", () => { phase: "unknown", receipt: { kind: "retained", artifactId: "retained-source" }, }); + await expect(f.manager.handleAlarm()).resolves.toBe("no_action"); + expect(f.storage.incrementCircuitBreakerFailure).toHaveBeenCalledOnce(); + expect(f.provider.takeSnapshot).not.toHaveBeenCalled(); await f.manager.spawnSandbox(); expect(f.provider.createSandbox).not.toHaveBeenCalled(); diff --git a/packages/control-plane/src/sandbox/lifecycle/manager.ts b/packages/control-plane/src/sandbox/lifecycle/manager.ts index 38db24e5f7..a76f486798 100644 --- a/packages/control-plane/src/sandbox/lifecycle/manager.ts +++ b/packages/control-plane/src/sandbox/lifecycle/manager.ts @@ -92,6 +92,13 @@ import { type AllocationCleanupStorage, } from "./allocation-cleanup"; import { boundedProviderStop, type ProviderStopOutcome } from "./provider-stop"; +import { + failConnectTimeout, + terminateStaleHeartbeat, + stopForInactivity, + type WatchdogContext, + type WatchdogEffectsDependencies, +} from "./watchdog-effects"; export type { SandboxGeneration, SandboxAlarmResult } from "./ports"; export type { AlarmScheduler } from "../../platform-ports"; @@ -312,14 +319,8 @@ export interface IdGenerator { * can yield long enough for a replacement spawn to install a new row, and a * stop aimed at `getSandbox()` afterwards would kill the replacement instead. */ -interface AlarmContext { - sandbox: SandboxRow; - now: number; +interface AlarmContext extends WatchdogContext { connectedClients: number; - /** Provider handle of the generation the alarm observed, if it has one. */ - providerObjectId: string | undefined; - /** Whether the persisted row still holds that same generation. */ - isCurrentGeneration: () => boolean; } // ==================== Configuration ==================== @@ -411,6 +412,7 @@ export class SandboxLifecycleManager private readonly launchContext: SandboxLaunchContext; private readonly vmStartup: VmStartupReconciliation; private readonly allocationCleanup: AllocationCleanupDependencies; + private readonly watchdogEffects: WatchdogEffectsDependencies; /** * Session-scoped logger. Falls back to the module-level logger if no @@ -478,6 +480,21 @@ export class SandboxLifecycleManager this.stopProviderSandbox("startup_superseded", "destroy", signal, providerObjectId), getLogger: () => this.log, }; + this.watchdogEffects = { + storage, + broadcaster, + sockets: wsManager, + shutdown, + access, + canStopProviderSandbox: () => this.canStopProviderSandbox(), + usesProviderManagedStop: () => this.usesProviderManagedStop(), + snapshotRequiresShutdown: () => !!provider.capabilities.snapshotRequiresShutdown, + recordSpawnFailure: (now, attemptStartedAt) => this.recordSpawnFailure(now, attemptStartedAt), + reportSandboxError: (reason) => this.reportSandboxError(reason), + triggerSnapshot: (reason) => this.triggerSnapshot(reason), + stopProviderSandboxSafely: (options) => this.stopProviderSandboxSafely(options), + getLogger: () => this.log, + }; } /** @@ -1521,16 +1538,27 @@ export class SandboxLifecycleManager return "no_action"; case "connecting_timeout": - return this.failConnectTimeout(finding.elapsedMs, context); + return failConnectTimeout( + this.watchdogEffects, + finding.elapsedMs, + this.config.connectingTimeout.timeoutMs, + context + ); case "heartbeat_stale": - return this.terminateStaleHeartbeat(finding.ageMs, finding.isBooting, context); + return terminateStaleHeartbeat( + this.watchdogEffects, + finding.ageMs, + finding.isBooting, + this.config.heartbeat.timeoutMs, + context + ); case "boot_budget_exceeded": return this.failBootBudget(finding.elapsedMs, context); case "inactivity_timeout": - return this.stopForInactivity(context); + return stopForInactivity(this.watchdogEffects, this.config.inactivity.timeoutMs, context); case "inactivity_warning": this.log.info("Inactivity extended", { @@ -1566,160 +1594,7 @@ export class SandboxLifecycleManager }); } - /** - * Give up on a generation whose bridge never arrived. The row is failed and - * the breaker charged before the provider stop, so a prompt landing mid-stop - * learns the spawn died instead of waiting on the provider to confirm it. - */ - private async failConnectTimeout( - elapsedMs: number, - ctx: AlarmContext - ): Promise { - this.log.warn("Connecting timeout", { - event: "sandbox.connecting_timeout", - elapsed_ms: elapsedMs, - timeout_ms: this.config.connectingTimeout.timeoutMs, - }); - this.storage.updateSandboxStatus("failed"); - this.recordSpawnFailure(ctx.now, ctx.sandbox.created_at); - this.access.clearAccess(); - const held = this.holdFailedRetainedBoot( - ctx.sandbox, - "Sandbox failed to connect within the allowed time" - ); - if (!held && this.canStopProviderSandbox()) { - // Fenced before the stop: a bridge arriving while the stop is in - // flight is refused at the door instead of self-healing into a - // container being killed. Where the provider cannot be stopped the - // row stays unfenced, so a boot that outlives the watchdog (#1905) - // can still connect and serve the session. - this.storage.fenceSandboxGeneration(); - await this.stopProviderSandboxSafely({ - reason: "connecting_timeout", - intent: "destroy", - providerObjectId: ctx.providerObjectId, - generationCreatedAtMs: ctx.sandbox.created_at, - failureMessage: "Provider stop failed after connecting timeout", - }); - } - this.broadcaster.broadcast({ type: "sandbox_status", status: "failed" }); - this.reportSandboxError( - held - ? "Sandbox failed to connect within the allowed time." - : "Sandbox failed to connect within the allowed time. It will be retried on your next message." - ); - return "sandbox_failed"; - } - - /** - * Terminate a generation that stopped heartbeating. What the sandbox was - * doing chooses the recovery: a provider that owns its state gets a - * preserving stop, a boot that died mid-flight is destroyed with no - * snapshot, and a ready sandbox is snapshotted so the session can resume. - */ - private async terminateStaleHeartbeat( - ageMs: number, - isBooting: boolean, - ctx: AlarmContext - ): Promise { - this.log.warn("Heartbeat stale", { - event: "sandbox.heartbeat_stale", - last_heartbeat_ms: ageMs, - threshold_ms: this.config.heartbeat.timeoutMs, - sandbox_status: ctx.sandbox.status, - }); - if (!isBooting && this.provider.capabilities.snapshotRequiresShutdown) { - // These providers save only on the way down, and a runtime that stopped - // heartbeating cannot take part in a graceful drain. The coordinator - // captures the source without it, then stops it. - const ownership = await this.shutdown.requestShutdown("heartbeat_timeout", "emergency"); - if (ownership !== "unmanaged") return "no_action"; - } - this.storage.updateSandboxStatus("stale"); - // A bridge that connected and then died mid-boot is a boot failure - // like any other; the termination re-drives the queue, and the breaker - // is what bounds a boot that dies the same way every time. - if (isBooting) this.recordSpawnFailure(ctx.now, ctx.sandbox.created_at); - this.access.clearAccess(); - this.broadcaster.broadcast({ type: "sandbox_status", status: "stale" }); - - const preservesProviderState = this.usesProviderManagedStop(); - if (preservesProviderState || isBooting) { - // `usesProviderManagedStop()` already implies `canStopProviderSandbox()`, - // so this guard only screens the booting case. Never snapshot a - // half-booted filesystem: it would be recorded as the restore point, and - // the next spawn would boot from it and skip the setup it never - // finished. No shutdown either — the row is already `stale`, which the - // send path refuses, and a bridge that stopped heartbeating is not there - // to receive it. - if (this.canStopProviderSandbox()) { - await this.stopProviderSandboxSafely({ - reason: "heartbeat_timeout", - intent: preservesProviderState ? "preserve" : "destroy", - providerObjectId: ctx.providerObjectId, - generationCreatedAtMs: ctx.sandbox.created_at, - failureMessage: "Provider stop failed after heartbeat timeout", - }); - } - } else { - if ((await this.snapshotAndStopStaleSandbox(ctx)) === "abandoned") return "no_action"; - if (!ctx.isCurrentGeneration()) return "no_action"; - this.wsManager.sendToSandbox({ type: "shutdown" }); - } - - if (!ctx.isCurrentGeneration()) return "no_action"; - this.wsManager.detachSandboxWebSocket(1000, "Heartbeat stale"); - return "sandbox_terminated"; - } - - /** - * Preserve a ready sandbox that stopped heartbeating, then stop it. - * - * Where the provider can be stopped the snapshot is awaited first, because - * the stop would otherwise race it; where it cannot, the snapshot runs - * detached so the status broadcast is not held behind it. Resolves - * "abandoned" when a shutdown or a replacement generation took over while - * the snapshot was in flight, which is the caller's cue to touch nothing - * further. - */ - private async snapshotAndStopStaleSandbox(ctx: AlarmContext): Promise<"stopped" | "abandoned"> { - if (!this.canStopProviderSandbox()) { - // Fire-and-forget snapshot so status broadcast isn't delayed. - this.triggerSnapshot("heartbeat_timeout").catch((e) => - this.log.error("Heartbeat snapshot failed", { - error: e instanceof Error ? e : String(e), - }) - ); - return "stopped"; - } - - await this.triggerSnapshot("heartbeat_timeout"); - if (this.shutdown.isHolding()) return "abandoned"; - if (!ctx.isCurrentGeneration()) return "abandoned"; - await this.stopProviderSandboxSafely({ - reason: "heartbeat_timeout", - intent: "destroy", - providerObjectId: ctx.providerObjectId, - generationCreatedAtMs: ctx.sandbox.created_at, - failureMessage: "Provider stop failed after heartbeat timeout", - }); - return "stopped"; - } - - /** - * Give up on a boot that outlived its budget. Order matters: the `shutdown` - * goes out first, while the socket is still adoptable (the lifecycle send - * path refuses a failed row); the generation is then fenced so a runtime - * that ignores the shutdown, or reconnects, is refused at the door and its - * supervisor exits — which is how a provider with no explicit stop is - * stopped; only then is the row failed. The failure is published and - * persisted before the provider stop yields, and the spawn guard is held - * across it, so a prompt arriving mid-stop neither waits to learn the boot - * died nor reserves a replacement that inherits this failure. A boot of the - * retained source is held instead, and its runtime and sandbox are left for - * the recovery. Returns the failure text so the alarm handler can fail the - * pending prompt with the same words. - */ + /** Publish and detach before guarding explicit stop; leave a held retained source intact. */ private async failBootBudget(elapsedMs: number, ctx: AlarmContext): Promise { const bootPhase = parseStoredSandboxBootPhase(ctx.sandbox.boot_phase); const reason = formatBootBudgetFailure( @@ -1761,63 +1636,6 @@ export class SandboxLifecycleManager return { kind: "boot_budget_exceeded", reason }; } - /** - * Stop an idle sandbox. A provider that can resume in place keeps its own - * state; otherwise the filesystem is snapshotted first so the next prompt - * restores rather than rebuilds. - */ - private async stopForInactivity(ctx: AlarmContext): Promise { - const ownership = await this.shutdown.requestShutdown("inactivity_timeout"); - if (ownership !== "unmanaged") return "no_action"; - - this.log.info("Inactivity timeout", { - event: "sandbox.timeout", - last_activity: ctx.sandbox.last_activity, - timeout_ms: this.config.inactivity.timeoutMs, - }); - // Set status to stopped FIRST to block reconnection attempts - this.storage.updateSandboxStatus("stopped"); - this.access.clearAccess(); - this.broadcaster.broadcast({ type: "sandbox_status", status: "stopped" }); - - const preservesProviderState = this.usesProviderManagedStop(); - if (preservesProviderState) { - await this.stopProviderSandboxSafely({ - reason: "inactivity_timeout", - intent: "preserve", - providerObjectId: ctx.providerObjectId, - generationCreatedAtMs: ctx.sandbox.created_at, - failureMessage: "Provider stop failed after inactivity timeout", - level: "error", - }); - } else { - await this.triggerSnapshot("inactivity_timeout"); - if (this.shutdown.isHolding()) return "no_action"; - if (!ctx.isCurrentGeneration()) return "no_action"; - this.wsManager.sendToSandbox({ type: "shutdown" }); - if (this.canStopProviderSandbox()) { - await this.stopProviderSandboxSafely({ - reason: "inactivity_timeout", - intent: "destroy", - providerObjectId: ctx.providerObjectId, - generationCreatedAtMs: ctx.sandbox.created_at, - failureMessage: "Provider stop failed after inactivity timeout", - level: "error", - }); - } - } - - if (!ctx.isCurrentGeneration()) return "no_action"; - this.wsManager.detachSandboxWebSocket(1000, "Inactivity timeout"); - this.broadcaster.broadcast({ - type: "sandbox_warning", - message: preservesProviderState - ? "Sandbox stopped due to inactivity" - : "Sandbox stopped due to inactivity, snapshot saved", - }); - return "sandbox_terminated"; - } - private isCurrentSandboxState(expected: SandboxRow): boolean { const current = this.storage.getSandbox(); return ( diff --git a/packages/control-plane/src/sandbox/lifecycle/pending-vm-respawn.test.ts b/packages/control-plane/src/sandbox/lifecycle/pending-vm-respawn.test.ts index 8934af43e9..b0d858b318 100644 --- a/packages/control-plane/src/sandbox/lifecycle/pending-vm-respawn.test.ts +++ b/packages/control-plane/src/sandbox/lifecycle/pending-vm-respawn.test.ts @@ -87,7 +87,11 @@ describe("pending VM reference recovery", () => { throw new ModalApiError("snapshot unavailable", 500); }), }; - const provider = new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm"); + const provider = new ModalSandboxProvider( + client as unknown as ModalClient, + "modal-vm", + "github" + ); let state: ShutdownRecord | null = action === "restore" && setupDelayMs ? { @@ -212,7 +216,7 @@ describe("pending VM reference recovery", () => { const client = { createSandbox: vi.fn(), stopSandbox: vi.fn(async () => {}) }; const fixture = createAlarmFixture( sandbox, - new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm") + new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm", "github") ); fixture.shutdown.recordPendingProviderHandle = vi.fn(async () => { sandbox.modal_sandbox_id = "replacement-generation"; @@ -255,7 +259,7 @@ describe("pending VM reference recovery", () => { }; const fixture = createAlarmFixture( sandbox, - new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm") + new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm", "github") ); await fixture.manager.spawnSandbox(); vi.setSystemTime(Date.now() + DEFAULT_LIFECYCLE_CONFIG.bootBudget.timeoutMs + 1); @@ -284,7 +288,11 @@ describe("pending VM reference recovery", () => { throw new ModalApiError("not visible", 409, "pending_reference_not_visible"); }), }; - const provider = new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm"); + const provider = new ModalSandboxProvider( + client as unknown as ModalClient, + "modal-vm", + "github" + ); const first = createAlarmFixture(sandbox, provider); void first.manager.spawnSandbox(); await vi.waitFor(() => expect(client.createSandbox).toHaveBeenCalledOnce()); @@ -304,7 +312,7 @@ describe("pending VM reference recovery", () => { const createSandbox = vi.fn(); const fixture = createAlarmFixture( sandbox, - new ModalSandboxProvider({ createSandbox } as unknown as ModalClient, "modal-vm") + new ModalSandboxProvider({ createSandbox } as unknown as ModalClient, "modal-vm", "github") ); let finishLookup!: (value: undefined) => void; vi.mocked(fixture.storage.getUserEnvVars).mockImplementationOnce( @@ -330,7 +338,8 @@ describe("pending VM reference recovery", () => { throw new ModalApiError("not visible", 409, "pending_reference_not_visible"); }), } as unknown as ModalClient, - "modal-vm" + "modal-vm", + "github" ); const sandbox = createMockSandbox({ status: "failed", @@ -364,7 +373,8 @@ describe("pending VM reference recovery", () => { throw new ModalApiError("not visible", 409, "pending_reference_not_visible"); }), } as unknown as ModalClient, - "modal-vm" + "modal-vm", + "github" ); const sandbox = createMockSandbox({ status: "failed", @@ -393,7 +403,8 @@ describe("pending VM reference recovery", () => { })); const provider = new ModalSandboxProvider( { stopSandbox, restoreSandbox } as unknown as ModalClient, - "modal-vm" + "modal-vm", + "github" ); const sandbox = createMockSandbox({ status: "stopped", diff --git a/packages/control-plane/src/sandbox/lifecycle/rejected-allocation.test.ts b/packages/control-plane/src/sandbox/lifecycle/rejected-allocation.test.ts index 2d5acda6d5..825ea67bd0 100644 --- a/packages/control-plane/src/sandbox/lifecycle/rejected-allocation.test.ts +++ b/packages/control-plane/src/sandbox/lifecycle/rejected-allocation.test.ts @@ -53,7 +53,11 @@ describe("rejected provider allocation", () => { throw new Error("response lost"); }), }; - const provider = new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm"); + const provider = new ModalSandboxProvider( + client as unknown as ModalClient, + "modal-vm", + "github" + ); const fixture = createAlarmFixture(sandbox, provider); await fixture.manager.spawnSandbox(); expect(sandbox.status).toBe("ready"); @@ -90,7 +94,7 @@ describe("rejected provider allocation", () => { }; const fixture = createAlarmFixture( sandbox, - new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm") + new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm", "github") ); const spawning = fixture.manager.spawnSandbox(); try { @@ -422,7 +426,11 @@ describe("rejected provider allocation", () => { throw new ModalApiError("not visible", 409, "pending_reference_not_visible"); }), }; - const provider = new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm"); + const provider = new ModalSandboxProvider( + client as unknown as ModalClient, + "modal-vm", + "github" + ); const stop = vi.spyOn(provider, "stopSandbox"); const fixture = createAlarmFixture(sandbox, provider); expect(await fixture.manager.handleShutdownAlarm()).toBe("hold_watchdogs"); diff --git a/packages/control-plane/src/sandbox/lifecycle/vm-resolve.test.ts b/packages/control-plane/src/sandbox/lifecycle/vm-resolve.test.ts index f01449f317..36fbb0554a 100644 --- a/packages/control-plane/src/sandbox/lifecycle/vm-resolve.test.ts +++ b/packages/control-plane/src/sandbox/lifecycle/vm-resolve.test.ts @@ -80,7 +80,7 @@ function fixture(action: "create" | "restore" = "create", imageBuildLookup?: Ima ), stopSandbox: vi.fn(async () => {}), }; - const provider = new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm"); + const provider = new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm", "github"); const backgroundTasks = { submit: vi.fn((task: () => Promise) => void task()) }; let state: ShutdownRecord | null = null; const store = { diff --git a/packages/control-plane/src/sandbox/lifecycle/watchdog-effects.ts b/packages/control-plane/src/sandbox/lifecycle/watchdog-effects.ts new file mode 100644 index 0000000000..b36f5295f0 --- /dev/null +++ b/packages/control-plane/src/sandbox/lifecycle/watchdog-effects.ts @@ -0,0 +1,265 @@ +import type { ServerMessage } from "@open-inspect/shared/types/server-messages"; +import type { SandboxStatus } from "@open-inspect/shared/types/sessions"; +import type { Logger } from "../../logger"; +import type { SandboxRow } from "../../session/types"; +import type { StopConfig } from "../provider"; +import type { SandboxAlarmResult, SandboxGeneration } from "./ports"; +import type { SandboxAccess } from "./sandbox-access"; + +/** Generation-pinned facts captured by the manager before alarm effects can yield. */ +export interface WatchdogContext { + sandbox: SandboxRow; + now: number; + providerObjectId: string | undefined; + isCurrentGeneration: () => boolean; +} + +export interface WatchdogEffectsDependencies { + storage: { + updateSandboxStatus(status: SandboxStatus): void; + fenceSandboxGeneration(): void; + }; + broadcaster: { broadcast(message: ServerMessage): void }; + sockets: { + sendToSandbox(message: object): boolean; + detachSandboxWebSocket(code: number, reason: string): void; + }; + shutdown: { + isHolding(): boolean; + requestShutdown( + reason: string, + mode?: "graceful" | "emergency" + ): Promise<"owned" | "unmanaged" | "held">; + holdFailedRetainedBoot(reason: string, generation: SandboxGeneration): boolean; + }; + access: Pick; + canStopProviderSandbox: () => boolean; + usesProviderManagedStop: () => boolean; + snapshotRequiresShutdown: () => boolean; + recordSpawnFailure: (now: number, attemptStartedAt: number) => void; + reportSandboxError: (reason: string) => void; + triggerSnapshot: (reason: string) => Promise; + stopProviderSandboxSafely: (options: { + reason: string; + intent: StopConfig["intent"]; + providerObjectId: string | undefined; + generationCreatedAtMs: number; + failureMessage: string; + level?: "warn" | "error"; + }) => Promise; + getLogger: () => Pick; +} + +/** Fail and charge the boot before stop; providers without stop permit late bridge self-heal. */ +export async function failConnectTimeout( + deps: Pick< + WatchdogEffectsDependencies, + | "storage" + | "broadcaster" + | "shutdown" + | "access" + | "canStopProviderSandbox" + | "recordSpawnFailure" + | "reportSandboxError" + | "stopProviderSandboxSafely" + | "getLogger" + >, + elapsedMs: number, + timeoutMs: number, + ctx: WatchdogContext +): Promise { + deps.getLogger().warn("Connecting timeout", { + event: "sandbox.connecting_timeout", + elapsed_ms: elapsedMs, + timeout_ms: timeoutMs, + }); + deps.storage.updateSandboxStatus("failed"); + deps.recordSpawnFailure(ctx.now, ctx.sandbox.created_at); + deps.access.clearAccess(); + const held = deps.shutdown.holdFailedRetainedBoot( + "Sandbox failed to connect within the allowed time", + { sandboxId: ctx.sandbox.modal_sandbox_id, createdAt: ctx.sandbox.created_at } + ); + if (!held && deps.canStopProviderSandbox()) { + // Refuse a bridge arriving during stop, but don't fence an unstoppable late boot. + deps.storage.fenceSandboxGeneration(); + await deps.stopProviderSandboxSafely({ + reason: "connecting_timeout", + intent: "destroy", + providerObjectId: ctx.providerObjectId, + generationCreatedAtMs: ctx.sandbox.created_at, + failureMessage: "Provider stop failed after connecting timeout", + }); + } + deps.broadcaster.broadcast({ type: "sandbox_status", status: "failed" }); + deps.reportSandboxError( + held + ? "Sandbox failed to connect within the allowed time." + : "Sandbox failed to connect within the allowed time. It will be retried on your next message." + ); + return "sandbox_failed"; +} + +/** Preserve a ready workspace, but never turn an incomplete boot into a restore point. */ +export async function terminateStaleHeartbeat( + deps: Pick< + WatchdogEffectsDependencies, + | "storage" + | "broadcaster" + | "sockets" + | "shutdown" + | "access" + | "canStopProviderSandbox" + | "usesProviderManagedStop" + | "snapshotRequiresShutdown" + | "recordSpawnFailure" + | "triggerSnapshot" + | "stopProviderSandboxSafely" + | "getLogger" + >, + ageMs: number, + isBooting: boolean, + timeoutMs: number, + ctx: WatchdogContext +): Promise { + deps.getLogger().warn("Heartbeat stale", { + event: "sandbox.heartbeat_stale", + last_heartbeat_ms: ageMs, + threshold_ms: timeoutMs, + sandbox_status: ctx.sandbox.status, + }); + if (!isBooting && deps.snapshotRequiresShutdown()) { + // A missing runtime cannot drain; the coordinator captures and retires the source. + const ownership = await deps.shutdown.requestShutdown("heartbeat_timeout", "emergency"); + if (ownership !== "unmanaged") return "no_action"; + } + deps.storage.updateSandboxStatus("stale"); + if (isBooting) deps.recordSpawnFailure(ctx.now, ctx.sandbox.created_at); + deps.access.clearAccess(); + deps.broadcaster.broadcast({ type: "sandbox_status", status: "stale" }); + + const preservesProviderState = deps.usesProviderManagedStop(); + if (preservesProviderState || isBooting) { + // No snapshot of a half-booted filesystem or shutdown to an absent bridge. + if (deps.canStopProviderSandbox()) { + await deps.stopProviderSandboxSafely({ + reason: "heartbeat_timeout", + intent: preservesProviderState ? "preserve" : "destroy", + providerObjectId: ctx.providerObjectId, + generationCreatedAtMs: ctx.sandbox.created_at, + failureMessage: "Provider stop failed after heartbeat timeout", + }); + } + } else { + if ((await snapshotAndStopStaleSandbox(deps, ctx)) === "abandoned") return "no_action"; + if (!ctx.isCurrentGeneration()) return "no_action"; + deps.sockets.sendToSandbox({ type: "shutdown" }); + } + + if (!ctx.isCurrentGeneration()) return "no_action"; + deps.sockets.detachSandboxWebSocket(1000, "Heartbeat stale"); + return "sandbox_terminated"; +} + +/** Await capture before explicit stop; otherwise leave capture detached as before. */ +export async function snapshotAndStopStaleSandbox( + deps: Pick< + WatchdogEffectsDependencies, + | "canStopProviderSandbox" + | "triggerSnapshot" + | "shutdown" + | "stopProviderSandboxSafely" + | "getLogger" + >, + ctx: WatchdogContext +): Promise<"stopped" | "abandoned"> { + if (!deps.canStopProviderSandbox()) { + deps.triggerSnapshot("heartbeat_timeout").catch((e) => + deps.getLogger().error("Heartbeat snapshot failed", { + error: e instanceof Error ? e : String(e), + }) + ); + return "stopped"; + } + + await deps.triggerSnapshot("heartbeat_timeout"); + if (deps.shutdown.isHolding()) return "abandoned"; + if (!ctx.isCurrentGeneration()) return "abandoned"; + await deps.stopProviderSandboxSafely({ + reason: "heartbeat_timeout", + intent: "destroy", + providerObjectId: ctx.providerObjectId, + generationCreatedAtMs: ctx.sandbox.created_at, + failureMessage: "Provider stop failed after heartbeat timeout", + }); + return "stopped"; +} + +/** Shutdown owns inactivity first; only unmanaged work uses the legacy fallback. */ +export async function stopForInactivity( + deps: Pick< + WatchdogEffectsDependencies, + | "storage" + | "broadcaster" + | "sockets" + | "shutdown" + | "access" + | "canStopProviderSandbox" + | "usesProviderManagedStop" + | "triggerSnapshot" + | "stopProviderSandboxSafely" + | "getLogger" + >, + timeoutMs: number, + ctx: WatchdogContext +): Promise { + const ownership = await deps.shutdown.requestShutdown("inactivity_timeout"); + if (ownership !== "unmanaged") return "no_action"; + + deps.getLogger().info("Inactivity timeout", { + event: "sandbox.timeout", + last_activity: ctx.sandbox.last_activity, + timeout_ms: timeoutMs, + }); + // Block reconnect before retiring access or yielding to capture/stop. + deps.storage.updateSandboxStatus("stopped"); + deps.access.clearAccess(); + deps.broadcaster.broadcast({ type: "sandbox_status", status: "stopped" }); + + const preservesProviderState = deps.usesProviderManagedStop(); + if (preservesProviderState) { + await deps.stopProviderSandboxSafely({ + reason: "inactivity_timeout", + intent: "preserve", + providerObjectId: ctx.providerObjectId, + generationCreatedAtMs: ctx.sandbox.created_at, + failureMessage: "Provider stop failed after inactivity timeout", + level: "error", + }); + } else { + await deps.triggerSnapshot("inactivity_timeout"); + if (deps.shutdown.isHolding()) return "no_action"; + if (!ctx.isCurrentGeneration()) return "no_action"; + deps.sockets.sendToSandbox({ type: "shutdown" }); + if (deps.canStopProviderSandbox()) { + await deps.stopProviderSandboxSafely({ + reason: "inactivity_timeout", + intent: "destroy", + providerObjectId: ctx.providerObjectId, + generationCreatedAtMs: ctx.sandbox.created_at, + failureMessage: "Provider stop failed after inactivity timeout", + level: "error", + }); + } + } + + if (!ctx.isCurrentGeneration()) return "no_action"; + deps.sockets.detachSandboxWebSocket(1000, "Inactivity timeout"); + deps.broadcaster.broadcast({ + type: "sandbox_warning", + message: preservesProviderState + ? "Sandbox stopped due to inactivity" + : "Sandbox stopped due to inactivity, snapshot saved", + }); + return "sandbox_terminated"; +} diff --git a/packages/control-plane/src/sandbox/provider-factory.ts b/packages/control-plane/src/sandbox/provider-factory.ts index 90615f3e1a..94468625f9 100644 --- a/packages/control-plane/src/sandbox/provider-factory.ts +++ b/packages/control-plane/src/sandbox/provider-factory.ts @@ -35,7 +35,7 @@ function createModalProviderFromEnv(env: Env, backend: "modal" | "modal-vm"): Mo env.MODAL_API_URL ); - return createModalProvider(client, backend); + return createModalProvider(client, backend, resolveScmProviderFromEnv(env.SCM_PROVIDER)); } function createVercelProviderFromEnv(env: Env): VercelSandboxProvider { diff --git a/packages/control-plane/src/sandbox/providers/modal-backends.test.ts b/packages/control-plane/src/sandbox/providers/modal-backends.test.ts index 7a8324af27..0808fcc8bc 100644 --- a/packages/control-plane/src/sandbox/providers/modal-backends.test.ts +++ b/packages/control-plane/src/sandbox/providers/modal-backends.test.ts @@ -2,8 +2,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createModalClient, type ModalClient } from "../client"; import { SandboxLaunchRejectedError } from "../provider"; import { ModalSandboxProvider } from "./modal-provider"; +import { scmCloneIdentity } from "../sandbox-env"; import { resolveSandboxDashboardUrl } from "../../session/sandbox-access"; +const scmIdentity = scmCloneIdentity("github"); const config = { sessionId: "session-1", sandboxId: "sandbox-1", @@ -51,7 +53,7 @@ function fixture(confirmation: unknown) { }; return { client, - provider: new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm"), + provider: new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm", "github"), }; } @@ -110,7 +112,7 @@ describe("distinct Modal backend identities", () => { it("accepts older standard Modal responses without a backend echo", async () => { const { client } = fixture(undefined); - const provider = new ModalSandboxProvider(client as unknown as ModalClient, "modal"); + const provider = new ModalSandboxProvider(client as unknown as ModalClient, "modal", "github"); await expect(provider.createSandbox(config)).resolves.toMatchObject({ providerObjectId: "sb-1", }); @@ -172,7 +174,11 @@ describe("distinct Modal backend identities", () => { ) ); const client = createModalClient("test-secret", "workspace"); - const result = await client.createImageBuildSandbox({ ...build, sandboxBackend: "modal-vm" }); + const result = await client.createImageBuildSandbox({ + ...build, + scmIdentity, + sandboxBackend: "modal-vm", + }); expect(result).toMatchObject({ providerSessionId: "sb-1", sandboxBackend: { invalid: true } }); }); @@ -190,14 +196,19 @@ describe("distinct Modal backend identities", () => { ); vi.stubGlobal("fetch", fetchMock); const client = createModalClient("test-secret", "workspace"); - await client.createSandbox({ ...config, sandboxBackend: "modal-vm" }); + await client.createSandbox({ ...config, scmIdentity, sandboxBackend: "modal-vm" }); fetchMock.mockResolvedValue( Response.json({ success: true, data: { sandbox_id: "sandbox-1", modal_object_id: "sb-1", sandbox_backend: "modal-vm" }, }) ); - await client.restoreSandbox({ ...config, snapshotImageId: "im-1", sandboxBackend: "modal-vm" }); + await client.restoreSandbox({ + ...config, + scmIdentity, + snapshotImageId: "im-1", + sandboxBackend: "modal-vm", + }); for (const [, init] of fetchMock.mock.calls) { expect(JSON.parse(init.body)).toMatchObject({ sandbox_backend: "modal-vm", diff --git a/packages/control-plane/src/sandbox/providers/modal-provider.test.ts b/packages/control-plane/src/sandbox/providers/modal-provider.test.ts index c5120e13e7..4af50bfcd3 100644 --- a/packages/control-plane/src/sandbox/providers/modal-provider.test.ts +++ b/packages/control-plane/src/sandbox/providers/modal-provider.test.ts @@ -11,6 +11,7 @@ import { PENDING_VM_REFERENCE_MATERIALIZE_BOUND_MS } from "../lifecycle/decision import { PrebuiltImageUnavailableError, SandboxProviderError } from "../provider"; import { ModalApiError, ModalVmStartupError } from "../client"; import { RequestDeadlineError } from "../request-deadline"; +import { scmCloneIdentity } from "../sandbox-env"; import type { ModalClient, CreateSandboxRequest, @@ -94,6 +95,24 @@ const testConfig = { // ==================== Tests ==================== describe("ModalSandboxProvider", () => { + it("sends the deployment SCM identity on create, prebuilt create, and restore", async () => { + const client = createMockModalClient(); + const provider = new ModalSandboxProvider(client, "modal", "gitlab"); + await provider.createSandbox(testConfig); + await provider.createSandbox({ ...testConfig, prebuiltImageId: "prebuilt-image" }); + await provider.restoreFromSnapshot({ ...testConfig, snapshotImageId: "img-123" }); + + const identity = { scmIdentity: scmCloneIdentity("gitlab") }; + expect(vi.mocked(client.createSandbox).mock.calls.map(([request]) => request)).toEqual([ + expect.objectContaining(identity), + expect.objectContaining({ ...identity, prebuiltImageId: "prebuilt-image" }), + ]); + expect(client.restoreSandbox).toHaveBeenCalledWith( + expect.objectContaining(identity), + undefined + ); + }); + it.each(["not_visible", "other_generation", "unknown"] as const)( "decodes raw and wrapped VM outcome %s without changing its classification", (detail) => { @@ -111,7 +130,7 @@ describe("ModalSandboxProvider", () => { ); it("standard Modal hooks do not enable VM allocation recovery", () => { - const provider = new ModalSandboxProvider(createMockModalClient(), "modal"); + const provider = new ModalSandboxProvider(createMockModalClient(), "modal", "github"); expect(provider.pendingSandboxAllocation(testConfig)).toBeUndefined(); expect(provider.isUnknownStartupError(new ModalApiError("unavailable", 503))).toBe(false); expect( @@ -136,7 +155,8 @@ describe("ModalSandboxProvider", () => { throw error; }, }), - "modal-vm" + "modal-vm", + "github" ); let caught: unknown; try { @@ -170,7 +190,8 @@ describe("ModalSandboxProvider", () => { sandboxBackend: "modal-vm", }), }), - "modal-vm" + "modal-vm", + "github" ); const config = { ...testConfig, generationCreatedAtMs: createdAt, timeoutSeconds: 5_400 }; const pending = provider.pendingSandboxAllocation(config); @@ -199,7 +220,7 @@ describe("ModalSandboxProvider", () => { try { vi.setSystemTime(new Date("2030-01-01T00:00:00.000Z")); const client = createMockModalClient(); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); const created = await provider.createSandbox({ ...testConfig, timeoutSeconds: 1200 }); expect(created.lifetime).toEqual({ kind: "finite", @@ -237,7 +258,7 @@ describe("ModalSandboxProvider", () => { describe("capabilities", () => { it("reports correct capabilities", () => { const client = createMockModalClient(); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); expect(provider.name).toBe("modal"); expect(provider.capabilities.supportsSnapshots).toBe(true); @@ -253,7 +274,7 @@ describe("ModalSandboxProvider", () => { throw new Error("fetch failed"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); await expect(provider.createSandbox(testConfig)).rejects.toThrow(SandboxProviderError); try { @@ -270,7 +291,7 @@ describe("ModalSandboxProvider", () => { throw new Error("connect ETIMEDOUT 192.168.1.1:443"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -286,7 +307,7 @@ describe("ModalSandboxProvider", () => { throw new Error("read ECONNRESET"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -302,7 +323,7 @@ describe("ModalSandboxProvider", () => { throw new Error("connect ECONNREFUSED 127.0.0.1:3000"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -318,7 +339,7 @@ describe("ModalSandboxProvider", () => { throw new Error("Network request failed"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -334,7 +355,7 @@ describe("ModalSandboxProvider", () => { throw new RequestDeadlineError("Modal", "createSandbox", 30_000); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -350,7 +371,7 @@ describe("ModalSandboxProvider", () => { throw new Error("Modal API error: 502 Bad Gateway"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -366,7 +387,7 @@ describe("ModalSandboxProvider", () => { throw new Error("Modal API error: 503 Service Unavailable"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -382,7 +403,7 @@ describe("ModalSandboxProvider", () => { throw new Error("Modal API error: 504 Gateway Timeout"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -398,7 +419,7 @@ describe("ModalSandboxProvider", () => { throw new Error("upstream bad gateway error"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -414,7 +435,7 @@ describe("ModalSandboxProvider", () => { throw new Error("service unavailable, try again later"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -430,7 +451,7 @@ describe("ModalSandboxProvider", () => { throw new Error("gateway timeout while waiting for upstream"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -448,7 +469,7 @@ describe("ModalSandboxProvider", () => { throw new Error("Modal API error: 401 Unauthorized"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -464,7 +485,7 @@ describe("ModalSandboxProvider", () => { throw new Error("Modal API error: 403 Forbidden"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -480,7 +501,7 @@ describe("ModalSandboxProvider", () => { throw new Error("Modal API error: 400 Bad Request - Invalid configuration"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -496,7 +517,7 @@ describe("ModalSandboxProvider", () => { throw new Error("Modal API error: 422 Unprocessable Entity"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -512,7 +533,7 @@ describe("ModalSandboxProvider", () => { throw new Error("Invalid repository configuration"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -528,7 +549,7 @@ describe("ModalSandboxProvider", () => { throw new Error("Quota exceeded: maximum sandboxes reached"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -544,7 +565,7 @@ describe("ModalSandboxProvider", () => { throw new Error("Something unexpected happened"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -560,7 +581,7 @@ describe("ModalSandboxProvider", () => { throw "string error"; // Throwing a string, not an Error }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -580,7 +601,7 @@ describe("ModalSandboxProvider", () => { throw originalError; }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -596,7 +617,7 @@ describe("ModalSandboxProvider", () => { throw new Error("timeout exceeded"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -622,7 +643,7 @@ describe("ModalSandboxProvider", () => { const client = createMockModalClient({ createSandbox: vi.fn(async () => expectedResult), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); const result = await provider.createSandbox({ ...testConfig, vncEnabled: true }); @@ -647,7 +668,7 @@ describe("ModalSandboxProvider", () => { }); await expect( - new ModalSandboxProvider(client, "modal").createSandbox({ + new ModalSandboxProvider(client, "modal", "github").createSandbox({ ...testConfig, prebuiltImageId: "im-missing", }) @@ -667,7 +688,7 @@ describe("ModalSandboxProvider", () => { }), }); - const error = await new ModalSandboxProvider(client, "modal") + const error = await new ModalSandboxProvider(client, "modal", "github") .createSandbox({ ...testConfig, prebuiltImageId: "im-valid" }) .catch((caught: unknown) => caught); @@ -679,7 +700,7 @@ describe("ModalSandboxProvider", () => { describe("image builds", () => { it("binds a created image-build sandbox before starting it", async () => { const client = createMockModalClient(); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); const correlation = { request_id: "request-1", trace_id: "trace-1" }; const onProviderSessionCreated = vi.fn(async () => undefined); @@ -707,6 +728,7 @@ describe("ModalSandboxProvider", () => { scopeId: "acme/repo", buildId: "build-123", repositories: [{ repoOwner: "acme", repoName: "repo", baseBranch: "develop" }], + scmIdentity: scmCloneIdentity("github"), cloneToken: "clone-token", callbackUrl: "https://worker.test/image-builds/build-complete", failureCallbackUrl: "https://worker.test/image-builds/build-failed", @@ -746,7 +768,8 @@ describe("ModalSandboxProvider", () => { }); const provider = new ModalSandboxProvider( createMockModalClient({ stopSandbox }), - "modal-vm" + "modal-vm", + "github" ); const config = { sessionId: "test-session", @@ -792,7 +815,7 @@ describe("ModalSandboxProvider", () => { throw new ModalApiError("Modal API error: 502 Bad Gateway", 502); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.restoreFromSnapshot({ @@ -820,7 +843,7 @@ describe("ModalSandboxProvider", () => { throw new ModalApiError("Modal API error: 401 Unauthorized", 401); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.restoreFromSnapshot({ @@ -849,7 +872,7 @@ describe("ModalSandboxProvider", () => { throw modalError; }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.takeSnapshot({ @@ -874,7 +897,8 @@ describe("ModalSandboxProvider", () => { })); const provider = new ModalSandboxProvider( createMockModalClient({ snapshotBuildSandbox }), - "modal" + "modal", + "github" ); await expect( @@ -900,7 +924,8 @@ describe("ModalSandboxProvider", () => { throw modalError; }), }), - "modal" + "modal", + "github" ); await expect( @@ -922,7 +947,7 @@ describe("ModalSandboxProvider", () => { vncPassword: "vnc-pw", })), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); const result = await provider.restoreFromSnapshot({ snapshotImageId: "img-123", diff --git a/packages/control-plane/src/sandbox/providers/modal-provider.ts b/packages/control-plane/src/sandbox/providers/modal-provider.ts index 48bc40d2fd..8428244095 100644 --- a/packages/control-plane/src/sandbox/providers/modal-provider.ts +++ b/packages/control-plane/src/sandbox/providers/modal-provider.ts @@ -15,6 +15,8 @@ import type { ModalClient, ModalBackend, CreateImageBuildSandboxResponse } from import type { SandboxSettings } from "@open-inspect/shared/types/integrations"; import type { CorrelationContext } from "../../logger"; import { supportsConfigurableSandboxTimeout } from "@open-inspect/shared/types/integrations"; +import type { SourceControlProviderName } from "../../source-control"; +import { scmCloneIdentity, type ScmCloneIdentity } from "../sandbox-env"; import { DEFAULT_SANDBOX_TIMEOUT_SECONDS, PrebuiltImageUnavailableError, @@ -54,11 +56,8 @@ interface StartModalImageBuildConfig { correlation?: CorrelationContext; } -/** Modal extends the shared trigger contract with explicit SCM clone identity. */ export interface ModalImageBuildTriggerConfig extends ImageBuildProviderTriggerConfig { resources?: Pick; - cloneHost?: string; - cloneUsername?: string; } export interface TerminateModalImageBuildConfig { @@ -96,7 +95,7 @@ export interface ModalImageBuildProvider { * @example * ```typescript * const client = createModalClient(secret, workspace, environmentWebSuffix); - * const provider = new ModalSandboxProvider(client, "modal"); + * const provider = new ModalSandboxProvider(client, "modal", "github"); * * try { * const result = await provider.createSandbox(config); @@ -189,11 +188,15 @@ export class ModalSandboxProvider implements SandboxProvider, ModalImageBuildPro return deadline; } + private readonly scmIdentity: ScmCloneIdentity; + constructor( private readonly client: ModalClient, - backend: ModalBackend + backend: ModalBackend, + scmProvider: SourceControlProviderName ) { this.name = backend; + this.scmIdentity = scmCloneIdentity(scmProvider); this.capabilities = { supportsSandboxTimeout: supportsConfigurableSandboxTimeout(this.name), supportsSnapshots: true, @@ -226,6 +229,7 @@ export class ModalSandboxProvider implements SandboxProvider, ModalImageBuildPro provider: config.provider, model: config.model, userEnvVars: config.userEnvVars, + scmIdentity: this.scmIdentity, prebuiltImageId: config.prebuiltImageId, prebuiltImageSha: config.prebuiltImageSha, timeoutSeconds, @@ -273,6 +277,7 @@ export class ModalSandboxProvider implements SandboxProvider, ModalImageBuildPro const result = await this.client.restoreSandbox( { snapshotImageId: config.snapshotImageId, + scmIdentity: this.scmIdentity, launchDeadlineAtMs, sessionId: config.sessionId, sandboxId: config.sandboxId, @@ -448,9 +453,8 @@ export class ModalSandboxProvider implements SandboxProvider, ModalImageBuildPro scopeId: config.scopeId, buildId: config.buildId, repositories: config.repositories, + scmIdentity: this.scmIdentity, cloneToken: config.cloneToken, - ...(config.cloneHost ? { cloneHost: config.cloneHost } : {}), - ...(config.cloneUsername ? { cloneUsername: config.cloneUsername } : {}), callbackUrl: config.callbackUrl, failureCallbackUrl: config.failureCallbackUrl, userEnvVars: config.userEnvVars, @@ -627,7 +631,8 @@ export class ModalSandboxProvider implements SandboxProvider, ModalImageBuildPro */ export function createModalProvider( client: ModalClient, - backend: ModalBackend + backend: ModalBackend, + scmProvider: SourceControlProviderName ): ModalSandboxProvider { - return new ModalSandboxProvider(client, backend); + return new ModalSandboxProvider(client, backend, scmProvider); } diff --git a/packages/control-plane/src/sandbox/providers/vercel/provider.test.ts b/packages/control-plane/src/sandbox/providers/vercel/provider.test.ts index a4ba615793..cd65952d9f 100644 --- a/packages/control-plane/src/sandbox/providers/vercel/provider.test.ts +++ b/packages/control-plane/src/sandbox/providers/vercel/provider.test.ts @@ -811,7 +811,6 @@ describe("VercelSandboxProvider", () => { ); expect(createCall.env).not.toHaveProperty("GITHUB_TOKEN"); expect(createCall.env).not.toHaveProperty("GITHUB_APP_TOKEN"); - expect(createCall.env).not.toHaveProperty("OI_GITHUB_TOKEN_IS_FALLBACK"); expect(createCall.env).not.toHaveProperty("OI_INTERNAL_CALLBACK_SECRET"); expect(createCall.env).not.toHaveProperty("OI_VERCEL_TOKEN"); expect(createCall.env).not.toHaveProperty("OI_VERCEL_CALLBACK_URL"); diff --git a/packages/control-plane/src/scheduler/scheduler.test.ts b/packages/control-plane/src/scheduler/scheduler.test.ts index 7e578ec3e0..70721d9ab8 100644 --- a/packages/control-plane/src/scheduler/scheduler.test.ts +++ b/packages/control-plane/src/scheduler/scheduler.test.ts @@ -13,9 +13,12 @@ import type { SqlDatabase } from "../db/sql-database"; import type { FetchClient } from "../platform-ports"; import { fakeSessionRuntimeDispatch } from "../router.test-support"; import type { Logger } from "../logger"; -import type { InvocationRunAggregate } from "../db/automation-store"; +import type { AutomationRow, InvocationRunAggregate } from "../db/automation-store"; import type { SlackAutomationEvent } from "@open-inspect/shared/triggers"; import { DEFAULT_AUTOMATION_MAX_CONCURRENT_RUNS } from "@open-inspect/shared/types/automations"; +import type { Team } from "@open-inspect/shared/types/teams"; +import type { EffectiveAuthorization } from "@open-inspect/shared/rbac"; +import type * as SessionAdmissionModule from "../authorization/session-admission"; const mockCheckRepositoryAccess = vi.hoisted(() => vi.fn()); const mockResolveSessionProviderAuth = vi.hoisted(() => @@ -25,7 +28,43 @@ const mockResolveSessionProviderAuth = vi.hoisted(() => ]) ); const mockIsAutomationExecutionAuthorized = vi.hoisted(() => vi.fn().mockResolvedValue(true)); -const mockIsPrincipalAuthorized = vi.hoisted(() => vi.fn().mockResolvedValue(true)); +const mockGetEffectiveAuthorization = vi.hoisted(() => vi.fn()); +const mockEvaluateSessionAdmission = vi.hoisted(() => vi.fn()); +const mockTeamGetById = vi.hoisted(() => + vi.fn<(id: string) => Promise>().mockResolvedValue(null) +); +const mockTeamGrantCovers = vi.hoisted(() => + vi + .fn<(teamId: string, repoIds: readonly (number | null)[]) => Promise>() + .mockResolvedValue(true) +); + +vi.mock("../db/teams", () => ({ + TeamStore: vi.fn().mockImplementation(function () { + return { getById: mockTeamGetById }; + }), +})); + +vi.mock("../db/team-repository-grants", () => ({ + TeamRepositoryGrantStore: vi.fn().mockImplementation(function () { + return { covers: mockTeamGrantCovers }; + }), +})); + +vi.mock("../authorization/service", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + AuthorizationService: vi.fn().mockImplementation(function () { + return { getEffectiveAuthorization: mockGetEffectiveAuthorization }; + }), + }; +}); + +vi.mock("../authorization/session-admission", async (importOriginal) => ({ + ...(await importOriginal()), + evaluateSessionAdmission: mockEvaluateSessionAdmission, +})); const mockGetGitHubAccessToken = vi.hoisted(() => vi.fn()); const mockGitHubAccountInfo = vi.hoisted(() => vi.fn()); @@ -50,7 +89,6 @@ vi.mock("../automation/authorization-guard", async (importOriginal) => { return { ...actual, isAutomationExecutionAuthorized: mockIsAutomationExecutionAuthorized, - isPrincipalAuthorized: mockIsPrincipalAuthorized, }; }); @@ -371,6 +409,7 @@ const now = Date.now(); const sampleAutomation = { id: "auto-1", + owner_team_id: null, name: "Daily sync", repo_owner: "acme", repo_name: "web-app", @@ -496,6 +535,18 @@ function makeSlackEvent(overrides?: Partial): SlackAutomat }; } +function steeringAuthorization( + overrides?: Partial +): EffectiveAuthorization { + return { + userId: "slack-actor-user", + suspendedAt: null, + role: { id: "role-member", key: "member", name: "Member" }, + permissions: ["sessions.read", "sessions.collaborate"], + ...overrides, + }; +} + /** All children handed to the last insertInvocationGuarded call, as inserted. */ function lastInsertedChildren(): Array> { const params = capturedInvocationParams.at(-1); @@ -516,7 +567,12 @@ describe("Scheduler", () => { ]); mockProviderAuthList.mockResolvedValue([]); mockIsAutomationExecutionAuthorized.mockResolvedValue(true); - mockIsPrincipalAuthorized.mockResolvedValue(true); + mockGetEffectiveAuthorization.mockReset().mockResolvedValue(steeringAuthorization()); + mockEvaluateSessionAdmission + .mockReset() + .mockResolvedValue({ kind: "allowed", legacyPermission: null }); + mockTeamGetById.mockReset().mockResolvedValue(null); + mockTeamGrantCovers.mockReset().mockResolvedValue(true); mockUserStoreGetIdentity.mockImplementation(async (provider: string) => provider === "slack" ? { userId: "slack-actor-user" } : null ); @@ -2324,8 +2380,13 @@ describe("Scheduler", () => { mockIsAutomationExecutionAuthorized.mockResolvedValue(false); const scheduler = createScheduler(); - await expect(scheduler.trigger("auto-1", "user-1")).rejects.toBeInstanceOf( - AutomationExecutionUnauthorizedError + const denied = scheduler.trigger("auto-1", "user-1"); + await expect(denied).rejects.toBeInstanceOf(AutomationExecutionUnauthorizedError); + await expect(denied).rejects.toMatchObject({ + reason: "execution_authorization_denied", + }); + expect(new AutomationExecutionUnauthorizedError().reason).toBe( + "execution_authorization_denied" ); expect(mockStore.insertInvocationGuarded).not.toHaveBeenCalled(); }); @@ -2905,9 +2966,9 @@ describe("Scheduler", () => { sampleSlackAutomation, { ...sampleSlackAutomation, id: "auto-slack-2" }, ]); - mockStore.getLatestSteerableRunForThread.mockResolvedValue( - sampleRunRow({ id: "active-run", session_id: "sess-running" }) - ); + mockStore.getLatestSteerableRunForThread + .mockResolvedValueOnce(sampleRunRow({ id: "active-run-1", session_id: "sess-running-1" })) + .mockResolvedValueOnce(sampleRunRow({ id: "active-run-2", session_id: "sess-running-2" })); expect(await createScheduler().event(makeSlackEvent({ text: "follow up" }))).toEqual({ triggered: 0, @@ -2916,7 +2977,112 @@ describe("Scheduler", () => { }); expect(mockUserStoreGetIdentity).toHaveBeenCalledTimes(1); - expect(mockIsPrincipalAuthorized).toHaveBeenCalledTimes(1); + expect(mockGetEffectiveAuthorization).toHaveBeenCalledTimes(1); + expect(mockGetEffectiveAuthorization).toHaveBeenCalledWith("slack-actor-user"); + expect(mockEvaluateSessionAdmission.mock.calls.map(([, , id]) => id)).toEqual([ + "sess-running-1", + "sess-running-2", + ]); + }); + + describe("Slack steering session admission", () => { + const actorUserId = "slack-actor-user"; + const sessionId = "sess-steering"; + + beforeEach(() => { + mockGetSlackAutomationsForChannel.mockResolvedValue([sampleSlackAutomation]); + mockStore.getLatestSteerableRunForThread.mockResolvedValue( + sampleRunRow({ session_id: sessionId }) + ); + }); + + it.each([ + "unresolved actor", + "missing collaboration permission", + "suspended actor", + "authorization unavailable", + ])("fails closed before session admission for %s", async (scenario) => { + if (scenario === "unresolved actor") mockUserStoreGetIdentity.mockResolvedValue(null); + if (scenario === "missing collaboration permission") { + mockGetEffectiveAuthorization.mockResolvedValue( + steeringAuthorization({ permissions: [] }) + ); + } + if (scenario === "suspended actor") { + mockGetEffectiveAuthorization.mockResolvedValue( + steeringAuthorization({ suspendedAt: 1 }) + ); + } + if (scenario === "authorization unavailable") { + mockGetEffectiveAuthorization.mockRejectedValue(new Error("authorization unavailable")); + } + const stub = createMockSessionStub(); + + expect(await createScheduler(createEnv(undefined, stub)).event(makeSlackEvent())).toEqual({ + triggered: 0, + skipped: 0, + steered: 0, + }); + expect(stub.fetch).not.toHaveBeenCalled(); + expect(mockEvaluateSessionAdmission).not.toHaveBeenCalled(); + expect(mockStore.insertInvocationGuarded).not.toHaveBeenCalled(); + if (scenario === "unresolved actor") { + expect(mockGetEffectiveAuthorization).not.toHaveBeenCalled(); + } else { + expect(mockGetEffectiveAuthorization).toHaveBeenCalledTimes(1); + expect(mockGetEffectiveAuthorization).toHaveBeenCalledWith(actorUserId); + } + }); + + it("does not steer or start a replacement run when the session index row is missing", async () => { + mockEvaluateSessionAdmission.mockResolvedValue({ kind: "not_found" }); + const stub = createMockSessionStub(); + + expect(await createScheduler(createEnv(undefined, stub)).event(makeSlackEvent())).toEqual({ + triggered: 0, + skipped: 0, + steered: 0, + }); + expect(stub.fetch).not.toHaveBeenCalled(); + expect(mockStore.insertInvocationGuarded).not.toHaveBeenCalled(); + }); + + it("checks each session instead of reusing the first admission", async () => { + const sessionIds = ["sess-allowed", "sess-denied"]; + mockGetSlackAutomationsForChannel.mockResolvedValue([ + sampleSlackAutomation, + { ...sampleSlackAutomation, id: "auto-slack-2" }, + ]); + mockStore.getLatestSteerableRunForThread + .mockResolvedValueOnce(sampleRunRow({ session_id: sessionIds[0] })) + .mockResolvedValueOnce(sampleRunRow({ session_id: sessionIds[1] })); + mockEvaluateSessionAdmission + .mockResolvedValueOnce({ kind: "allowed", legacyPermission: null }) + .mockResolvedValueOnce({ kind: "action_denied", reason: "not_collaborator" }); + const requests = vi.fn(async (_request: Request, _sessionId: string) => + Response.json({ messageId: "msg-steer", status: "queued" }) + ); + const env = createEnv({ TEAMS_ENFORCEMENT: "shadow" }); + env.SESSION = fakeSessionRuntimeDispatch(requests); + + expect(await createScheduler(env).event(makeSlackEvent())).toEqual({ + triggered: 0, + skipped: 0, + steered: 1, + }); + expect(requests).toHaveBeenCalledTimes(1); + expect(requests.mock.calls[0][1]).toBe("sess-allowed"); + expect(mockEvaluateSessionAdmission.mock.calls.map(([, , id]) => id)).toEqual(sessionIds); + for (const [ctx, , id, action, slot] of mockEvaluateSessionAdmission.mock.calls) { + expect(ctx.principal).toEqual({ kind: "user", userId: actorUserId }); + expect(sessionIds).toContain(id); + expect(action).toBe("collaborate"); + expect(slot).toBeNull(); + } + expect(mockGetEffectiveAuthorization).toHaveBeenCalledTimes(1); + expect(mockStore.insertInvocationGuarded).not.toHaveBeenCalled(); + expect(mockIsAutomationExecutionAuthorized).not.toHaveBeenCalled(); + }); }); it("continues the same session on a reply after the run has completed", async () => { @@ -3207,4 +3373,164 @@ describe("Scheduler", () => { expect(mockStore.insertSkippedInvocation).not.toHaveBeenCalled(); }); }); + + describe("current team repository grants", () => { + const teamId = "33333333333333333333333333333333"; + const activeTeam: Team = { + id: teamId, + slug: "automation-grants", + name: "Automation grants", + description: null, + joinPolicy: "invite_only", + defaultVisibility: "team", + defaultEnvironmentId: null, + grantsVersion: 1, + archivedAt: null, + createdAt: 1, + updatedAt: 1, + }; + const teamAutomation: AutomationRow = { + ...sampleAutomation, + harness: "opencode", + owner_team_id: teamId, + event_type: null, + trigger_config: null, + trigger_auth_data: null, + }; + const environment = { + id: "env_55555555555555555555555555555555", + owner_team_id: teamId, + name: "Full workspace", + }; + const members = [ + { + ...repositoryRow("auto-1", { base_branch: "main" }), + environment_id: environment.id, + position: 0, + }, + { + ...repositoryRow("auto-1", { repo_name: "api", repo_id: 67890, base_branch: "develop" }), + environment_id: environment.id, + position: 1, + }, + ]; + beforeEach(() => { + mockTeamGetById.mockResolvedValue(activeTeam); + mockStore.getById.mockResolvedValue(teamAutomation); + mockStore.getOverdueAutomations.mockResolvedValue([teamAutomation]); + mockEnvironmentGetById.mockReset().mockResolvedValue(environment); + mockEnvironmentRepositories.mockReset().mockResolvedValue(members); + }); + + it("checks resolved direct IDs before manual admission", async () => { + selectRepositories("auto-1", [repositoryRow("auto-1")]); + mockCheckRepositoryAccess.mockResolvedValue({ + repoId: 98765, + repoOwner: "acme", + repoName: "web-app", + defaultBranch: "main", + }); + mockTeamGrantCovers.mockResolvedValue(false); + + const stub = createMockSessionStub(); + await expect( + createScheduler(createEnv(undefined, stub)).trigger("auto-1", "manual-user") + ).rejects.toMatchObject({ reason: "target_team_missing_grant" }); + expect(mockTeamGrantCovers).toHaveBeenCalledWith(teamId, [98765]); + expect(mockCheckRepositoryAccess).toHaveBeenCalledTimes(1); + expect(mockStore.insertInvocationGuarded).not.toHaveBeenCalled(); + expect(mockStore.claimRunSession).not.toHaveBeenCalled(); + expect(mockSessionStoreCreate).not.toHaveBeenCalled(); + expect(stub.fetch).not.toHaveBeenCalled(); + expect(mockResolveSessionProviderAuth).not.toHaveBeenCalled(); + expect(mockStore.incrementConsecutiveFailures).not.toHaveBeenCalled(); + expect(mockStore.insertSkippedInvocation).not.toHaveBeenCalled(); + expect(mockStore.recordAuthorizationDenied).not.toHaveBeenCalled(); + }); + + it.each(["repository", "environment"] as const)( + "preserves a failed %s resolution while authorized healthy fan-out launches", + async (target) => { + selectRepositories("auto-1", [ + repositoryRow("auto-1"), + ...(target === "repository" + ? [repositoryRow("auto-1", { repo_name: "api", repo_id: 67890 })] + : []), + ]); + if (target === "environment") selectEnvironments("auto-1", [environment.id]); + mockCheckRepositoryAccess.mockImplementation(async ({ owner, name }) => + name === "api" + ? null + : { repoId: 12345, repoOwner: owner, repoName: name, defaultBranch: "main" } + ); + mockTeamGrantCovers.mockResolvedValue(true); + mockStore.getInvocationRunAggregate.mockResolvedValue( + aggregate({ total: 2, active: 1, failed: 1 }) + ); + + const result = await createScheduler().trigger("auto-1", "manual-user"); + + expect(result.runs).toEqual([ + expect.objectContaining({ repo_name: "web-app", status: "running" }), + expect.objectContaining({ + status: "failed", + failure_reason: + target === "repository" + ? "Repository is not accessible for the configured SCM provider" + : expect.stringContaining("acme/api"), + ...(target === "repository" + ? { repo_name: "api" } + : { environment_id: environment.id }), + }), + ]); + expect(mockTeamGrantCovers).toHaveBeenCalledWith(teamId, [12345]); + expect(mockTeamGrantCovers).toHaveBeenCalledTimes(1); + expect(mockStore.insertInvocationGuarded).toHaveBeenCalledTimes(1); + expect(mockSessionStoreCreate).toHaveBeenCalledTimes(1); + expect(mockStore.recordAuthorizationDenied).not.toHaveBeenCalled(); + expect(mockStore.incrementConsecutiveFailures).toHaveBeenCalledTimes(1); + } + ); + + it("keeps workspace environment resolution at launch time", async () => { + mockStore.getOverdueAutomations.mockResolvedValue([sampleAutomation]); + mockEnvironmentGetById.mockResolvedValue({ ...environment, owner_team_id: null }); + mockTeamGrantCovers.mockResolvedValue(false); + selectEnvironments("auto-1", [environment.id]); + mockStore.insertInvocationGuarded.mockImplementation(async (params: unknown) => { + capturedInvocationParams.push( + structuredClone(params) as { children: Array> } + ); + mockEnvironmentRepositories.mockResolvedValue([ + members[0], + { ...members[1], base_branch: "edited-after-admission" }, + ]); + return { inserted: true }; + }); + mockCheckRepositoryAccess.mockImplementation(async ({ owner, name }) => ({ + repoId: name === "api" ? 67890 : 12345, + repoOwner: owner, + repoName: name, + defaultBranch: "main", + })); + const stub = createMockSessionStub(); + + expect(await createScheduler(createEnv(undefined, stub)).tick()).toEqual({ + processed: 1, + skipped: 0, + failed: 0, + }); + expect(mockTeamGrantCovers).not.toHaveBeenCalled(); + expect(mockTeamGetById).not.toHaveBeenCalled(); + expect(mockSessionStoreCreate).toHaveBeenCalledTimes(1); + expect(mockStore.recordAuthorizationDenied).not.toHaveBeenCalled(); + expect(mockEnvironmentRepositories.mock.invocationCallOrder[0]).toBeGreaterThan( + mockStore.insertInvocationGuarded.mock.invocationCallOrder[0] + ); + expect((await getInitBody(vi.mocked(stub.fetch))).repositories).toEqual([ + { repoOwner: "acme", repoName: "web-app", repoId: 12345, baseBranch: "main" }, + { repoOwner: "acme", repoName: "api", repoId: 67890, baseBranch: "edited-after-admission" }, + ]); + }); + }); }); diff --git a/packages/control-plane/src/scheduler/scheduler.ts b/packages/control-plane/src/scheduler/scheduler.ts index a12760ffad..f0ed6a940b 100644 --- a/packages/control-plane/src/scheduler/scheduler.ts +++ b/packages/control-plane/src/scheduler/scheduler.ts @@ -65,6 +65,10 @@ import { import { getUserAuth } from "../auth/user/runtime"; import { GitHubAttributionUnavailableError } from "../source-control/github-credential-authority"; import { UserStore } from "../db/user-store"; +import { TeamStore } from "../db/teams"; +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import { AuthorizationService } from "../authorization/service"; +import { evaluateSessionAdmission } from "../authorization/session-admission"; import { createRequestMetrics } from "../db/instrumented-sql-database"; import { generateId } from "../auth/crypto"; import { createLogger, parseLogLevel } from "../logger"; @@ -84,11 +88,11 @@ import { MAX_IMAGE_BUILD_PROVIDER_SESSION_TIMEOUT_MS } from "../image-builds/tim import { resolveManagedSkills } from "../session/skill-resolution"; import type { EnqueuePromptRequest } from "../session/enqueue-prompt-contract"; import { resolveAutomationRepositories } from "../automation/repository"; -import { resolveAutomationSessionTarget } from "../automation/session-target"; import { - isAutomationExecutionAuthorized, - isPrincipalAuthorized, -} from "../automation/authorization-guard"; + resolveAutomationSessionTarget, + type AutomationSessionTarget, +} from "../automation/session-target"; +import { isAutomationExecutionAuthorized } from "../automation/authorization-guard"; import type { RequestContext } from "../routes/shared"; import { deliverWithRetry } from "../session/callback-delivery"; import { @@ -251,9 +255,16 @@ export interface SchedulerTriggerResult { runs: AutomationRun[]; } +/** Why a firing was refused without recording an invocation. */ +export type AutomationTriggerBlockedReason = "concurrent_run_active" | "team_grants_changed"; + export class AutomationTriggerBlockedError extends Error { - constructor() { - super("An active run already exists"); + constructor(readonly reason: AutomationTriggerBlockedReason = "concurrent_run_active") { + super( + reason === "team_grants_changed" + ? "Team repository grants changed during admission" + : "An active run already exists" + ); this.name = "AutomationTriggerBlockedError"; } } @@ -261,7 +272,7 @@ export class AutomationTriggerBlockedError extends Error { /** Raised when an automation's execution principal lacks required authorization. */ export class AutomationExecutionUnauthorizedError extends Error { /** Create an error for an unauthorized automation execution principal. */ - constructor() { + constructor(readonly reason = "execution_authorization_denied") { super("Automation execution principal is not authorized"); this.name = "AutomationExecutionUnauthorizedError"; } @@ -307,12 +318,15 @@ type StartInvocationResult = | { outcome: "started"; invocationId: string; runs: AutomationRunRow[]; launched: number } /** Overlap — a childless skipped invocation was recorded (schedule/event). */ | { outcome: "skipped" } - /** Overlap on a manual firing — nothing recorded; the caller answers 409. */ - | { outcome: "blocked" } + /** + * Overlap on a manual firing, or team grants changed during admission — nothing + * recorded; manual callers answer 409. + */ + | { outcome: "blocked"; reason: AutomationTriggerBlockedReason } /** Idempotency/dedup collision — another firing owns this slot or event. */ | { outcome: "deduplicated" } /** The execution principal cannot launch the immutable target snapshot. */ - | { outcome: "unauthorized" }; + | { outcome: "unauthorized"; reason?: string }; type SchedulerPromptRequest = Pick< EnqueuePromptRequest, @@ -350,6 +364,19 @@ export function composeAutomationPrompt(contextBlock: string, instructions: stri } /** Coordinates authorized automation scheduling, dispatch, and completion handling. */ +/** A launch candidate's target as resolved at admission, or why it could not be resolved. */ +type LaunchTargetSnapshot = { target: AutomationSessionTarget } | { error: unknown }; + +/** Repository IDs that the resolved launch targets will open. */ +function launchTargetRepoIds(snapshots: ReadonlyMap): number[] { + return [...snapshots.values()].flatMap((snapshot) => { + if (!("target" in snapshot)) return []; + const { target } = snapshot; + if (target.repositories) return target.repositories.map((repository) => repository.repoId); + return target.repoId === null ? [] : [target.repoId]; + }); +} + export class Scheduler { private readonly log: Logger; @@ -464,6 +491,11 @@ export class Scheduler { params.repositories ?? store.getRepositoriesForAutomation(automation.id), params.environments ?? store.getEnvironmentsForAutomation(automation.id), ]); + // One snapshot of the owning team classifies denials and pins the grants version. + const team = + automation.owner_team_id === null + ? null + : await new TeamStore(this.db).getById(automation.owner_team_id); if ( !(await isAutomationExecutionAuthorized(this.db, { automationId: automation.id, @@ -472,7 +504,10 @@ export class Scheduler { requiresEnvironmentUse: environmentSelection.length > 0, })) ) { - return { outcome: "unauthorized" }; + return { + outcome: "unauthorized", + reason: team?.archivedAt != null ? "team_archived" : "execution_authorization_denied", + }; } const resolutions = await resolveAutomationRepositories(this.env, selection); @@ -502,10 +537,9 @@ export class Scheduler { // One child per target. Repository children snapshot the resolved repo; a // failed resolution pre-fails its child (snapshot from the selection row) - // without blocking siblings. Environment children snapshot the environment - // id — the workspace itself resolves at launch time (design §13.3), so a - // deleted environment fails through the launch-failure path. No targets → - // one repo-less child. + // without blocking siblings. Environment children snapshot their id; team + // workspaces are resolved before grant admission below. Resolution errors + // remain launch failures. No targets produce one repo-less child. const children: AutomationRunRow[] = [ ...resolutions.map( (resolution): AutomationRunRow => ({ @@ -532,6 +566,23 @@ export class Scheduler { } const launchCandidates = children.filter((child) => child.status === "starting"); + let launchTargets: ReadonlyMap = new Map(); + let teamGrantsVersion: { teamId: string; version: number } | undefined; + if (automation.owner_team_id !== null) { + if (!team) return { outcome: "unauthorized", reason: "execution_authorization_denied" }; + // Snapshot the grants version before checking coverage; the guarded insert + // refuses admission if grants change in between. + teamGrantsVersion = { teamId: team.id, version: team.grantsVersion }; + launchTargets = await this.resolveLaunchTargets(automation, launchCandidates); + if ( + !(await new TeamRepositoryGrantStore(this.db).covers( + team.id, + launchTargetRepoIds(launchTargets) + )) + ) { + return { outcome: "unauthorized", reason: "target_team_missing_grant" }; + } + } // Resolve provider routing before admission, alongside the already-built // target children. Together these values are the immutable launch snapshot // for this firing: edits made after the conditional insert cannot change which @@ -573,6 +624,7 @@ export class Scheduler { invocation, children, overlapScope, + teamGrantsVersion, advanceSchedule: source === "schedule" && params.scheduledAt !== undefined && @@ -595,6 +647,14 @@ export class Scheduler { throw e; } + if (!inserted && teamGrantsVersion) { + const current = await new TeamStore(this.db).getById(teamGrantsVersion.teamId); + if (current?.grantsVersion !== teamGrantsVersion.version) { + // Grants changed after coverage was checked. Nothing was recorded or + // advanced, so a schedule slot refires and re-authorizes on the next tick. + return { outcome: "blocked", reason: "team_grants_changed" }; + } + } if (!inserted) { // Raced an active invocation between the pre-check and the batch. The // batch's schedule advance already ran (deliberately unconditional), so @@ -634,6 +694,8 @@ export class Scheduler { try { if (attributionError !== undefined) throw attributionError; if ("error" in providerAuthSnapshot) throw providerAuthSnapshot.error; + const targetSnapshot = launchTargets.get(child.id); + if (targetSnapshot && "error" in targetSnapshot) throw targetSnapshot.error; const sessionId = generateId(); // Claim the generated session before initialization. Otherwise the orphan sweep can // terminalize an old `starting` row while initialization is still creating its session. @@ -658,7 +720,8 @@ export class Scheduler { providerAuthSnapshot.providerAuth, sessionId, executionPrincipal, - claimedAt + claimedAt, + targetSnapshot && "target" in targetSnapshot ? targetSnapshot.target : undefined ); await this.sendPromptToSession( sessionId, @@ -743,7 +806,7 @@ export class Scheduler { params: StartInvocationParams, options: { advanceSchedule: boolean } ): Promise { - if (params.source === "manual") return { outcome: "blocked" }; + if (params.source === "manual") return { outcome: "blocked", reason: "concurrent_run_active" }; const now = Date.now(); await store.insertSkippedInvocation( @@ -852,7 +915,7 @@ export class Scheduler { trigger_key: null, concurrency_key: null, trigger_metadata: null, - skip_reason: "execution_authorization_denied", + skip_reason: result.reason ?? "execution_authorization_denied", failure_counted_at: null, created_at: deniedAt, updated_at: deniedAt, @@ -863,6 +926,7 @@ export class Scheduler { event: "scheduler.authorization_denied", automation_id: automation.id, scheduled_at: automation.next_run_at, + reason_code: result.reason ?? "execution_authorization_denied", }); skipped++; break; @@ -1102,8 +1166,10 @@ export class Scheduler { slackContextPromise ??= this.buildSlackContextWithThread(slackEvent); return slackContextPromise; }; - let slackSteeringActorPromise: Promise | undefined; - const slackSteeringActor = (slackEvent: SlackAutomationEvent): Promise => { + let slackSteeringActorPromise: Promise | undefined; + const slackSteeringActor = ( + slackEvent: SlackAutomationEvent + ): Promise => { slackSteeringActorPromise ??= (async () => { try { const identity = await new UserStore(this.db).getIdentity( @@ -1111,9 +1177,23 @@ export class Scheduler { slackEvent.actorUserId ); if (!identity) return null; - return (await isPrincipalAuthorized(this.db, identity.userId, "sessions.collaborate")) - ? identity.userId - : null; + const authorization = await new AuthorizationService(this.db).getEffectiveAuthorization( + identity.userId + ); + if ( + authorization.suspendedAt !== null || + !authorization.permissions.includes("sessions.collaborate") + ) + return null; + return { + db: this.db, + trace_id: `automation:slack-steering:${slackEvent.triggerKey}`, + request_id: slackEvent.triggerKey, + metrics: createRequestMetrics(), + executionCtx: this.backgroundJobs, + principal: { kind: "user", userId: identity.userId }, + authorization, + } satisfies RequestContext; } catch (error) { this.log.warn("Failed to authorize slack actor for session steering", { event: "scheduler.slack_steer_authorization_failed", @@ -1154,8 +1234,27 @@ export class Scheduler { now - SLACK_THREAD_CONTINUITY_WINDOW_MS ); if (steerable?.session_id) { - const actorUserId = await slackSteeringActor(event); - if (!actorUserId) { + const actor = await slackSteeringActor(event); + let canSteer = false; + if (actor?.authorization) { + try { + const admission = await evaluateSessionAdmission( + actor, + this.env, + steerable.session_id, + "collaborate", + null + ); + canSteer = admission.kind === "allowed"; + } catch (error) { + this.log.warn("Failed to authorize slack actor for the thread session", { + event: "scheduler.slack_steer_authorization_failed", + session_id: steerable.session_id, + error: error instanceof Error ? error : new Error(String(error)), + }); + } + } + if (!actor?.authorization || !canSteer) { this.log.warn("Blocked slack steering for unauthorized actor", { event: "scheduler.slack_steer_unauthorized", automation_id: automation.id, @@ -1164,7 +1263,7 @@ export class Scheduler { }); continue; } - if (await this.steerSession(steerable, automation, event, actorUserId)) { + if (await this.steerSession(steerable, automation, event, actor.authorization.userId)) { steered++; continue; } @@ -1244,6 +1343,7 @@ export class Scheduler { event: "scheduler.authorization_denied", automation_id: automation.id, source: event.source, + reason_code: result.reason ?? "execution_authorization_denied", }); skipped++; break; @@ -1293,8 +1393,9 @@ export class Scheduler { }); if (result.outcome === "unauthorized") { - throw new AutomationExecutionUnauthorizedError(); + throw new AutomationExecutionUnauthorizedError(result.reason); } + if (result.outcome === "blocked") throw new AutomationTriggerBlockedError(result.reason); if (result.outcome !== "started") { // Manual overlap (pre-check or lost race) records nothing. throw new AutomationTriggerBlockedError(); @@ -1591,6 +1692,39 @@ export class Scheduler { return resolveExecutionBudgetMs(sandboxSettings, this.env) + EXECUTION_DEADLINE_GRACE_MS; } + /** + * Resolve each launch candidate's session target once, at admission, so grant coverage is + * checked against exactly what launch will open. Resolution errors become launch failures. + */ + private async resolveLaunchTargets( + automation: AutomationRow, + candidates: readonly AutomationRunRow[] + ): Promise> { + const snapshots = new Map(); + await Promise.all( + candidates.map(async (child) => { + try { + const target = await resolveAutomationSessionTarget( + this.env, + child, + { + trace_id: `automation:${automation.id}`, + request_id: child.id, + metrics: createRequestMetrics(), + db: this.db, + executionCtx: this.backgroundJobs, + }, + this.log + ); + snapshots.set(child.id, { target }); + } catch (error) { + snapshots.set(child.id, { error }); + } + }) + ); + return snapshots; + } + private async createSessionForAutomationRun( store: AutomationStore, automation: AutomationRow, @@ -1599,7 +1733,8 @@ export class Scheduler { sessionId: string, executionPrincipal: ExecutionPrincipal, /** The instant the run claimed this session — what its deadline measures from. */ - startedAt: number + startedAt: number, + authorizedTarget?: AutomationSessionTarget ): Promise { const ctx: RequestContext = { trace_id: `automation:${automation.id}`, @@ -1609,11 +1744,11 @@ export class Scheduler { executionCtx: this.backgroundJobs, }; - // What the session opens — the run's repository snapshot or, for - // environment-bound automations, the environment's workspace. All target - // semantics live in resolveAutomationSessionTarget; a resolution failure - // throws into launchChild's failure path. - const target = await resolveAutomationSessionTarget(this.env, run, ctx, this.log); + if (automation.owner_team_id !== null && !authorizedTarget) { + throw new Error("Team automation launch is missing its admitted target"); + } + const target = + authorizedTarget ?? (await resolveAutomationSessionTarget(this.env, run, ctx, this.log)); // Session-scoped integration settings resolve from the primary member // (design §6.2), with environment-bound runs layering that environment's @@ -1647,10 +1782,18 @@ export class Scheduler { ); const scmEnrichment = executionPrincipal.scmEnrichment; + // Re-read at launch: a team archived after invocation admission must not start sessions. + const team = + automation.owner_team_id === null + ? null + : await new TeamStore(this.db).getById(automation.owner_team_id); + if (automation.owner_team_id !== null && (!team || team.archivedAt !== null)) { + throw new AutomationExecutionUnauthorizedError("team_archived"); + } const sessionInput: SessionInitInput = { - ownerTeamId: null, - visibility: "workspace", + ownerTeamId: automation.owner_team_id, + visibility: team?.defaultVisibility ?? "workspace", sessionId, ...target, title: `[Auto] ${automation.name}`, diff --git a/packages/control-plane/src/session/components.credentials.test.ts b/packages/control-plane/src/session/components.credentials.test.ts new file mode 100644 index 0000000000..76895296a9 --- /dev/null +++ b/packages/control-plane/src/session/components.credentials.test.ts @@ -0,0 +1,126 @@ +import { DatabaseSync } from "node:sqlite"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SessionIndexStore, type SessionEntry } from "../db/session-index"; +import { SessionRepositoryStore } from "../db/session-repositories"; +import { createNodeSqlDatabase } from "../node/sqlite-database"; +import { createNodeSqlStorage } from "../node/sqlite-storage"; +import { readCachedInstallationRepositories } from "../repos/cache"; +import type { SourceControlProvider } from "../source-control"; +import type { Env } from "../types"; +import { createSessionRuntime } from "./components"; +import { buildSessionInternalRequest, SessionInternalPaths } from "./contracts"; +import { initSchema } from "./schema"; + +vi.mock("../repos/cache", () => ({ readCachedInstallationRepositories: vi.fn() })); + +describe("session credential scope composition", () => { + let sqlite: DatabaseSync; + + beforeEach(() => { + sqlite = new DatabaseSync(":memory:"); + vi.mocked(readCachedInstallationRepositories).mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + function createHarness() { + const storage = createNodeSqlStorage(sqlite); + initSchema(storage.sql); + storage.sql.exec( + `INSERT INTO session (id, session_name, repo_owner, repo_name, created_at, updated_at) + VALUES ('internal-session', 'public-session', 'acme', 'web', 1, 1)` + ); + const db = createNodeSqlDatabase(sqlite); + const env = { + REPO_SECRETS_ENCRYPTION_KEY: btoa("0".repeat(32)), + MODAL_API_SECRET: "modal-secret", + MODAL_WORKSPACE: "test-workspace", + LOG_LEVEL: "error", + } as Env; + const runtime = createSessionRuntime( + { + id: "durable-object-id", + storage, + db, + alarmStore: { + getAlarm: async () => null, + setAlarm: async () => {}, + deleteAlarm: async () => {}, + }, + sockets: { + adopt: () => {}, + tags: () => [], + sockets: () => [], + setAutoResponse: () => {}, + }, + createBackgroundTasks: () => ({ submit: () => {} }), + }, + env + ); + const generateCredentialHelperAuth = vi.fn(async () => ({ + username: "x-access-token", + password: "scoped-token", + expiresAtEpochMs: Date.now() + 60_000, + })); + runtime.internals.sourceControlProvider = { + name: "github", + generateCredentialHelperAuth, + } as unknown as SourceControlProvider; + return { + env, + generateCredentialHelperAuth, + getCredentials: () => + runtime.server.onRequest( + buildSessionInternalRequest(SessionInternalPaths.scmCredentials, { method: "POST" }) + ), + }; + } + + it("mints credentials for the scope resolved from D1 for the public session id", async () => { + const getSession = vi + .spyOn(SessionIndexStore.prototype, "get") + .mockResolvedValue({ id: "public-session", ownerTeamId: null } as SessionEntry); + vi.spyOn(SessionRepositoryStore.prototype, "listRepositoryIds").mockResolvedValue([ + { repoOwner: "acme", repoName: "web", repoId: null }, + { repoOwner: "acme", repoName: "api", repoId: 456 }, + ]); + vi.mocked(readCachedInstallationRepositories).mockResolvedValue([ + { + id: 123, + owner: "acme", + name: "web", + fullName: "acme/web", + description: null, + private: true, + defaultBranch: "main", + archived: false, + }, + ]); + const h = createHarness(); + + expect((await h.getCredentials()).status).toBe(200); + + expect(getSession).toHaveBeenCalledWith("public-session"); + expect(readCachedInstallationRepositories).toHaveBeenCalledWith(h.env); + expect(h.generateCredentialHelperAuth).toHaveBeenCalledWith({ + kind: "repositories", + repositoryIds: [123, 456], + }); + }); + + it("fails closed when the D1 session is missing despite an existing local session", async () => { + vi.spyOn(SessionIndexStore.prototype, "get").mockResolvedValue(null); + const h = createHarness(); + + const response = await h.getCredentials(); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ + error: "Cannot resolve credential scope: session not found", + }); + expect(h.generateCredentialHelperAuth).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/control-plane/src/session/components.ts b/packages/control-plane/src/session/components.ts index 98d940bfe4..c58b8c94f9 100644 --- a/packages/control-plane/src/session/components.ts +++ b/packages/control-plane/src/session/components.ts @@ -45,6 +45,8 @@ import { type SandboxShutdownLifecycle, } from "../sandbox/lifecycle/manager"; import type { ImageBuildLookup } from "../sandbox/lifecycle/image-selection"; +// The composition root supplies launch integration ports, not consumer-facing launch mechanics. +// eslint-disable-next-line no-restricted-imports import type { McpServerLookup, SlackAgentNotifyLookup } from "../sandbox/lifecycle/launch-context"; // The composition root shares the internal access collaborator with shutdown and lifecycle only. // eslint-disable-next-line no-restricted-imports @@ -59,6 +61,8 @@ import { SessionCollaboratorStore } from "../db/session-collaborators"; import { parsePersistedSandboxSettings } from "../sandbox/settings"; import type { SandboxSettings } from "@open-inspect/shared/types/integrations"; import { createSourceControlProviderFromEnv, type SourceControlProvider } from "../source-control"; +import { resolveSessionCredentialScope } from "../source-control/session-scope"; +import { readCachedInstallationRepositories } from "../repos/cache"; import { requireRepoSecretsEncryptionKey } from "../env-validation"; import type { Env, ClientInfo } from "../types"; import type { SessionRow } from "./types"; @@ -323,6 +327,8 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi // Shared single instances/closures — every consumer below takes these // rather than re-deriving its own copy. const sessionIndexStore = new SessionIndexStore(db); + const resolveCredentialScope = (sessionId: string) => + resolveSessionCredentialScope(db, sessionId, () => readCachedInstallationRepositories(env)); const teamMembershipStore = new TeamMembershipStore(db); const sessionCollaboratorStore = new SessionCollaboratorStore(db); const sessionPullRequestStore = new SessionPullRequestStore(db); @@ -660,7 +666,8 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi sessionCoreRepository, artifactRepository, sourceControlProvider(), - sessionPullRequestStore + sessionPullRequestStore, + resolveCredentialScope ).then(({ updated, failures }) => { for (const artifact of updated) { messenger.broadcast({ type: "artifact_updated", artifact }); @@ -725,7 +732,9 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi return service.refresh(sessionRow); }; const getScmCredentials = (requestLog: Logger) => - new ScmCredentialsService(sourceControlProvider(), requestLog).getCredentials(); + new ScmCredentialsService(sourceControlProvider(), requestLog, () => + resolveCredentialScope(getPublicSessionId()) + ).getCredentials(); const sandboxHandler = new SandboxHandler( messageRepository, @@ -791,6 +800,7 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi artifactRepository, claims: prCreationClaims, sourceControlProvider: sourceControlProvider(), + resolveCredentialScope, log: requestLog, generateId: () => generateId(), pushBranchToRemote: (pushSpec) => pushService.pushBranchToRemote(pushSpec), diff --git a/packages/control-plane/src/session/message-repository.test.ts b/packages/control-plane/src/session/message-repository.test.ts index 10497cf78c..b065c04b3d 100644 --- a/packages/control-plane/src/session/message-repository.test.ts +++ b/packages/control-plane/src/session/message-repository.test.ts @@ -626,6 +626,27 @@ describe("MessageRepository", () => { expect(mock.calls[2].params[0]).toBe("execution_complete:msg-1"); }); + it("rejects malformed persisted completion state rows", () => { + mock.setData(`SELECT status, created_at, started_at FROM messages WHERE id = ?`, [ + { status: "processing", created_at: "1000", started_at: 1200 }, + ]); + + expect(() => + repository.recordMessageCompletion( + { + type: "execution_complete", + messageId: "msg-1", + success: true, + sandboxId: "sb-1", + timestamp: 3, + }, + 3000, + "processing" + ) + ).toThrow(SessionStorageIntegrityError); + expect(mock.calls).toHaveLength(1); + }); + it("does not complete a message in another state", () => { mock.setData(`SELECT status, created_at, started_at FROM messages WHERE id = ?`, [ { status: "completed", created_at: 1000, started_at: 1200 }, @@ -735,6 +756,35 @@ describe("MessageRepository", () => { expect(repository.getProcessingMessageAuthor()).toEqual({ author_id: "p-1" }); }); + it("reads nullable callback context rows", () => { + mock.setData(`SELECT callback_context, source FROM messages WHERE id = ?`, [ + { callback_context: null, source: "web" }, + ]); + + expect(repository.getMessageCallbackContext("msg-1")).toEqual({ + callback_context: null, + source: "web", + }); + }); + + it("rejects malformed persisted callback context rows", () => { + mock.setData(`SELECT callback_context, source FROM messages WHERE id = ?`, [ + { callback_context: 123, source: "slack" }, + ]); + + expect(() => repository.getMessageCallbackContext("msg-1")).toThrow( + SessionStorageIntegrityError + ); + }); + + it("rejects malformed persisted processing author rows", () => { + mock.setData(`SELECT author_id FROM messages WHERE status = 'processing' LIMIT 1`, [ + { author_id: null }, + ]); + + expect(() => repository.getProcessingMessageAuthor()).toThrow(SessionStorageIntegrityError); + }); + describe("raiseReportedCost", () => { it("returns the increase over the stored report", () => { mock.setMatchingData(/SELECT reported_cost_usd FROM messages/, [{ reported_cost_usd: 1 }]); diff --git a/packages/control-plane/src/session/message-repository.ts b/packages/control-plane/src/session/message-repository.ts index 5796501241..e4b77841a6 100644 --- a/packages/control-plane/src/session/message-repository.ts +++ b/packages/control-plane/src/session/message-repository.ts @@ -22,6 +22,16 @@ const messageStopConfirmationRowSchema = messageRowSchema .pick({ id: true, stop_confirmation_deadline: true }) .extend({ stop_confirmation_deadline: z.number() }); const messageCreatedAtRowSchema = messageRowSchema.pick({ id: true, created_at: true }); +const messageCallbackContextRowSchema = messageRowSchema.pick({ + callback_context: true, + source: true, +}); +const messageCompletionStateRowSchema = z.object({ + status: z.unknown().optional(), + created_at: z.number(), + started_at: z.number().nullable(), +}); +const messageProcessingAuthorRowSchema = messageRowSchema.pick({ author_id: true }); export interface RecordedMessageCompletion { messageId: string; @@ -351,10 +361,7 @@ export class MessageRepository { `SELECT callback_context, source FROM messages WHERE id = ?`, messageId ); - const rows = result.toArray() as Array<{ - callback_context: string | null; - source: string | null; - }>; + const rows = parseStorageRows(result.toArray(), messageCallbackContextRowSchema); return rows[0] ?? null; } @@ -447,13 +454,7 @@ export class MessageRepository { `SELECT status, created_at, started_at FROM messages WHERE id = ?`, event.messageId ); - const message = ( - result.toArray() as Array<{ - status?: unknown; - created_at: number; - started_at: number | null; - }> - )[0]; + const message = parseStorageRows(result.toArray(), messageCompletionStateRowSchema)[0]; const messageStatus = parseMessageStatus(message?.status); if (!message || messageStatus !== expectedStatus) return null; @@ -525,7 +526,7 @@ export class MessageRepository { const result = this.sql.exec( `SELECT author_id FROM messages WHERE status = 'processing' LIMIT 1` ); - const rows = result.toArray() as Array<{ author_id: string }>; + const rows = parseStorageRows(result.toArray(), messageProcessingAuthorRowSchema); return rows[0] ?? null; } } diff --git a/packages/control-plane/src/session/pull-request-refresh.test.ts b/packages/control-plane/src/session/pull-request-refresh.test.ts index ac5e4f40ff..3baab3b0dc 100644 --- a/packages/control-plane/src/session/pull-request-refresh.test.ts +++ b/packages/control-plane/src/session/pull-request-refresh.test.ts @@ -1,5 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { PullRequestSnapshot } from "../source-control"; +import { + SourceControlProviderError, + type CredentialScope, + type PullRequestSnapshot, +} from "../source-control"; import { refreshSessionPullRequests } from "./pull-request-refresh"; import type { ArtifactRepository } from "./artifact-repository"; import type { ArtifactRow, SessionRow } from "./types"; @@ -95,6 +99,8 @@ function createHarness(artifacts: ArtifactRow[], session: SessionRow | null = cr } as unknown as ArtifactRepository; const getPullRequest = vi.fn(async () => createSnapshot()); const upsert = vi.fn(async () => ({ applied: true })); + const credentialScope: CredentialScope = { kind: "repositories", repositoryIds: [123, 9001] }; + const resolveCredentialScope = vi.fn(async () => credentialScope); return { repository, @@ -102,8 +108,16 @@ function createHarness(artifacts: ArtifactRow[], session: SessionRow | null = cr rows, getPullRequest, upsert, + credentialScope, + resolveCredentialScope, refresh: () => - refreshSessionPullRequests(repository, artifactRepository, { getPullRequest }, { upsert }), + refreshSessionPullRequests( + repository, + artifactRepository, + { getPullRequest }, + { upsert }, + resolveCredentialScope + ), }; } @@ -129,12 +143,11 @@ describe("refreshSessionPullRequests", () => { type: "pr", metadata: expect.objectContaining({ lifecycleState: "merged" }), }); - expect(harness.getPullRequest).toHaveBeenCalledWith({ - owner: "acme", - name: "web", - number: 7, - repositoryExternalId: "9001", - }); + expect(harness.resolveCredentialScope).toHaveBeenCalledWith("public-session-1"); + expect(harness.getPullRequest).toHaveBeenCalledWith( + { owner: "acme", name: "web", number: 7, repositoryExternalId: "9001" }, + harness.credentialScope + ); expect(harness.upsert).toHaveBeenCalledWith({ artifactId: "artifact-1", sessionId: "public-session-1", @@ -199,12 +212,10 @@ describe("refreshSessionPullRequests", () => { await harness.refresh(); - expect(harness.getPullRequest).toHaveBeenCalledWith({ - owner: "acme", - name: "web", - number: 7, - repositoryExternalId: undefined, - }); + expect(harness.getPullRequest).toHaveBeenCalledWith( + { owner: "acme", name: "web", number: 7, repositoryExternalId: undefined }, + harness.credentialScope + ); }); it("reports artifacts whose metadata carries no PR number as not refreshable", async () => { @@ -259,6 +270,56 @@ describe("refreshSessionPullRequests", () => { expect(harness.artifactRepository.updateArtifact).toHaveBeenCalledTimes(1); }); + it("reports scope resolution failure without reading the provider", async () => { + const harness = createHarness([createPrArtifact()]); + const error = new SourceControlProviderError( + "Cannot resolve credential scope: session not found", + "permanent" + ); + harness.resolveCredentialScope.mockRejectedValue(error); + + const result = await harness.refresh(); + + expect(result).toEqual({ + updated: [], + failures: [ + { + artifactId: "artifact-1", + reason: "provider_read_failed", + prNumber: 7, + repoOwner: "acme", + repoName: "web", + error, + }, + ], + }); + expect(harness.getPullRequest).not.toHaveBeenCalled(); + }); + + it("resolves scope freshly before each artifact read", async () => { + const harness = createHarness([ + createPrArtifact(), + createPrArtifact({ id: "artifact-2", metadata: JSON.stringify({ number: 8 }) }), + ]); + const nextScope: CredentialScope = { kind: "repositories", repositoryIds: [456] }; + harness.resolveCredentialScope + .mockResolvedValueOnce(harness.credentialScope) + .mockResolvedValueOnce(nextScope); + + await harness.refresh(); + + expect(harness.getPullRequest).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ number: 7 }), + harness.credentialScope + ); + expect(harness.getPullRequest).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ number: 8 }), + nextScope + ); + }); + it("does not regress a mirror that a webhook push advanced during the pass's awaits", async () => { const harness = createHarness([createPrArtifact()]); // Simulate a webhook snapshot push interleaving with this pass: while diff --git a/packages/control-plane/src/session/pull-request-refresh.ts b/packages/control-plane/src/session/pull-request-refresh.ts index 1795242f67..cc70640e6c 100644 --- a/packages/control-plane/src/session/pull-request-refresh.ts +++ b/packages/control-plane/src/session/pull-request-refresh.ts @@ -12,7 +12,11 @@ import type { SessionArtifact } from "@open-inspect/shared/types/artifacts"; import type { SessionPullRequestStore } from "../db/session-pull-request-store"; -import type { PullRequestSnapshot, SourceControlProvider } from "../source-control"; +import type { + CredentialScope, + PullRequestSnapshot, + SourceControlProvider, +} from "../source-control"; import { parsePullRequestArtifactMetadata } from "./pull-request-snapshot"; import { applyPullRequestSnapshot } from "./pull-request-snapshot-apply"; import type { ArtifactRepository } from "./artifact-repository"; @@ -81,7 +85,8 @@ export async function refreshSessionPullRequests( repository: PullRequestRefreshRepository, artifactRepository: ArtifactRepository, sourceControlProvider: Pick, - sessionPullRequests: Pick + sessionPullRequests: Pick, + resolveCredentialScope: (sessionId: string) => Promise ): Promise { const updated: SessionArtifact[] = []; const failures: PullRequestRefreshFailure[] = []; @@ -106,12 +111,16 @@ export async function refreshSessionPullRequests( let snapshot: PullRequestSnapshot; try { - snapshot = await sourceControlProvider.getPullRequest({ - owner: target.repoOwner, - name: target.repoName, - number: target.prNumber, - repositoryExternalId: target.repositoryExternalId, - }); + const scope = await resolveCredentialScope(sessionId); + snapshot = await sourceControlProvider.getPullRequest( + { + owner: target.repoOwner, + name: target.repoName, + number: target.prNumber, + repositoryExternalId: target.repositoryExternalId, + }, + scope + ); } catch (error) { failures.push({ artifactId: artifact.id, diff --git a/packages/control-plane/src/session/pull-request-service.per-branch.test.ts b/packages/control-plane/src/session/pull-request-service.per-branch.test.ts index 9e8d62a3db..fd93ede224 100644 --- a/packages/control-plane/src/session/pull-request-service.per-branch.test.ts +++ b/packages/control-plane/src/session/pull-request-service.per-branch.test.ts @@ -7,7 +7,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Logger } from "../logger"; -import type { PullRequestSnapshot, SourceControlProvider } from "../source-control"; +import type { + CredentialScope, + PullRequestSnapshot, + SourceControlProvider, +} from "../source-control"; import { buildSessionRepositories } from "./repository-target"; import type { ArtifactRow, SessionRepositoryRow, SessionRow } from "./types"; import type { ArtifactRepository, CreateArtifactData } from "./artifact-repository"; @@ -210,6 +214,7 @@ function createTestHarness() { } as unknown as ArtifactRepository; const sessionPullRequests = { upsert: vi.fn(async () => ({ applied: true })) }; + const credentialScope: CredentialScope = { kind: "repositories", repositoryIds: [123, 456] }; let idCounter = 0; const deps: PullRequestServiceDeps = { @@ -217,6 +222,7 @@ function createTestHarness() { artifactRepository, claims: new PullRequestCreationClaims(), sourceControlProvider: provider, + resolveCredentialScope: vi.fn(async () => credentialScope), log, generateId: () => `id-${++idCounter}`, pushBranchToRemote: vi.fn(async () => ({ success: true as const })), @@ -230,6 +236,7 @@ function createTestHarness() { service: new SessionPullRequestService(deps), deps, provider, + credentialScope, artifacts, sessionPullRequests, setSession: (next: SessionRow | null) => { @@ -287,6 +294,10 @@ describe("per-branch pull requests", () => { expect(harness.deps.pushBranchToRemote).toHaveBeenCalledWith( expect.objectContaining({ targetBranch: "feature-x", force: true }) ); + expect(harness.provider.getPullRequest).toHaveBeenCalledWith( + { owner: "acme", name: "web", number: 7, repositoryExternalId: undefined }, + harness.credentialScope + ); expect(harness.provider.createPullRequest).not.toHaveBeenCalled(); expect(harness.artifacts.filter((artifact) => artifact.type === "pr")).toHaveLength(1); }); diff --git a/packages/control-plane/src/session/pull-request-service.test.ts b/packages/control-plane/src/session/pull-request-service.test.ts index 5fbf2b8879..3ba9a05c48 100644 --- a/packages/control-plane/src/session/pull-request-service.test.ts +++ b/packages/control-plane/src/session/pull-request-service.test.ts @@ -1,7 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ScmSettings } from "@open-inspect/shared/types/integrations"; import type { Logger } from "../logger"; -import type { SourceControlProvider } from "../source-control"; +import { + SourceControlProviderError, + type CredentialScope, + type SourceControlProvider, +} from "../source-control"; import * as branchResolution from "../source-control/branch-resolution"; import type { SessionRepositoryRow } from "./types"; import { buildSessionRepositories } from "./repository-target"; @@ -197,6 +201,7 @@ function createTestHarness(options: { scmSettings?: ScmSettings } = {}) { } as unknown as ArtifactRepository; const sessionPullRequests = { upsert: vi.fn(async () => ({ applied: true })) }; + const credentialScope: CredentialScope = { kind: "repositories", repositoryIds: [123, 456] }; let idCounter = 0; const deps: PullRequestServiceDeps = { @@ -204,6 +209,7 @@ function createTestHarness(options: { scmSettings?: ScmSettings } = {}) { artifactRepository, claims: new PullRequestCreationClaims(), sourceControlProvider: provider, + resolveCredentialScope: vi.fn(async () => credentialScope), log, generateId: () => `id-${++idCounter}`, pushBranchToRemote: vi.fn(async () => ({ success: true as const })), @@ -219,6 +225,7 @@ function createTestHarness(options: { scmSettings?: ScmSettings } = {}) { service, deps, provider, + credentialScope, artifacts, sessionPullRequests, log, @@ -311,6 +318,8 @@ describe("SessionPullRequestService", () => { const createPrCall = (harness.provider.createPullRequest as ReturnType).mock .calls[0]; expect(createPrCall[0]).toEqual({ authType: "app", token: "app-token" }); + expect(harness.deps.resolveCredentialScope).toHaveBeenCalledWith("session-name-1"); + expect(harness.provider.generatePushAuth).toHaveBeenCalledWith(harness.credentialScope); expect(artifactCreatedBroadcasts(harness.deps)).toHaveLength(1); expect(harness.deps.repository.updateSessionBranch).toHaveBeenCalledWith( "session-1", @@ -324,6 +333,25 @@ describe("SessionPullRequestService", () => { }); }); + it("fails push auth without minting a token when the credential scope cannot be resolved", async () => { + vi.mocked(harness.deps.resolveCredentialScope).mockRejectedValueOnce( + new SourceControlProviderError( + "Cannot resolve credential scope: session not found", + "permanent" + ) + ); + + const result = await harness.service.createPullRequest(createInput()); + + expect(result).toEqual({ + kind: "error", + status: 500, + error: "Cannot resolve credential scope: session not found", + }); + expect(harness.provider.generatePushAuth).not.toHaveBeenCalled(); + expect(harness.deps.pushBranchToRemote).not.toHaveBeenCalled(); + }); + it("uses the sanitized branch for push, PR creation, and branch sync", async () => { const result = await harness.service.createPullRequest( createInput({ headBranch: " Feature/Test " }) diff --git a/packages/control-plane/src/session/pull-request-service.ts b/packages/control-plane/src/session/pull-request-service.ts index 8b690ecf1b..c57749fd95 100644 --- a/packages/control-plane/src/session/pull-request-service.ts +++ b/packages/control-plane/src/session/pull-request-service.ts @@ -16,6 +16,7 @@ import { SourceControlProviderError, type SourceControlProvider, type SourceControlAuthContext, + type CredentialScope, type GitPushAuthContext, type GitPushSpec, type PullRequestSnapshot, @@ -153,6 +154,7 @@ export interface PullRequestServiceDeps { /** DO-instance-scoped in-flight claims — must outlive individual requests. */ claims: PullRequestCreationClaims; sourceControlProvider: SourceControlProvider; + resolveCredentialScope: (sessionId: string) => Promise; log: Logger; generateId: () => string; pushBranchToRemote: (pushSpec: GitPushSpec) => Promise; @@ -238,8 +240,10 @@ export class SessionPullRequestService { const draft = scmSettings.alwaysUseDraftMode === true || (input.draft ?? false); let pushAuth: GitPushAuthContext; + let credentialScope: CredentialScope; try { - pushAuth = await this.deps.sourceControlProvider.generatePushAuth(); + credentialScope = await this.deps.resolveCredentialScope(sessionId); + pushAuth = await this.deps.sourceControlProvider.generatePushAuth(credentialScope); this.deps.log.info("Generated fresh push auth token"); } catch (error) { this.deps.log.error("Failed to generate push auth", { @@ -308,6 +312,7 @@ export class SessionPullRequestService { headMatches, targetRepo, sessionId, + credentialScope, { sanitizedHeadBranch, generatedHeadBranch, @@ -496,6 +501,7 @@ export class SessionPullRequestService { matches: PrArtifactHeadMatch[], targetRepo: RepoIdentity, sessionId: string, + credentialScope: CredentialScope, head: { sanitizedHeadBranch: string; generatedHeadBranch: string; @@ -533,12 +539,15 @@ export class SessionPullRequestService { // opening a duplicate is not. let live: PullRequestSnapshot | null = null; try { - live = await this.deps.sourceControlProvider.getPullRequest({ - owner: targetRepo.repoOwner, - name: targetRepo.repoName, - number: candidate.prNumber, - repositoryExternalId: candidate.repositoryExternalId ?? undefined, - }); + live = await this.deps.sourceControlProvider.getPullRequest( + { + owner: targetRepo.repoOwner, + name: targetRepo.repoName, + number: candidate.prNumber, + repositoryExternalId: candidate.repositoryExternalId ?? undefined, + }, + credentialScope + ); } catch (error) { this.deps.log.warn("Could not read live PR state; using the stored artifact's", { pr_number: candidate.prNumber, diff --git a/packages/control-plane/src/session/scm-credentials-service.test.ts b/packages/control-plane/src/session/scm-credentials-service.test.ts index 828b1186ad..39d8f82cad 100644 --- a/packages/control-plane/src/session/scm-credentials-service.test.ts +++ b/packages/control-plane/src/session/scm-credentials-service.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it, vi } from "vitest"; import type { Logger } from "../logger"; -import type { SourceControlProvider } from "../source-control"; +import type { CredentialScope, SourceControlProvider } from "../source-control"; import { SourceControlProviderError } from "../source-control/errors"; import { ScmCredentialsService } from "./scm-credentials-service"; +const credentialScope: CredentialScope = { kind: "repositories", repositoryIds: [99, 123] }; + function createTestLogger(): Logger { return { debug: vi.fn(), @@ -24,6 +26,14 @@ function makeProvider( } as unknown as SourceControlProvider; } +function getCredentials( + provider: SourceControlProvider, + log = createTestLogger(), + resolveCredentialScope = async () => credentialScope +) { + return new ScmCredentialsService(provider, log, resolveCredentialScope).getCredentials(); +} + describe("ScmCredentialsService", () => { it("returns credentials on success", async () => { const expiresAtEpochMs = Date.now() + 60 * 60 * 1000; @@ -36,7 +46,7 @@ describe("ScmCredentialsService", () => { }), }); - const result = await new ScmCredentialsService(provider, createTestLogger()).getCredentials(); + const result = await getCredentials(provider); expect(result).toEqual({ ok: true, @@ -44,6 +54,7 @@ describe("ScmCredentialsService", () => { password: "ghs_token", expiresAtEpochMs, }); + expect(provider.generateCredentialHelperAuth).toHaveBeenCalledWith(credentialScope); }); it("rejects invalid provider credential payloads", async () => { @@ -56,7 +67,7 @@ describe("ScmCredentialsService", () => { }), }); - const result = await new ScmCredentialsService(provider, log).getCredentials(); + const result = await getCredentials(provider, log); expect(result).toEqual({ ok: false, @@ -78,7 +89,7 @@ describe("ScmCredentialsService", () => { }), }); - const result = await new ScmCredentialsService(provider, createTestLogger()).getCredentials(); + const result = await getCredentials(provider); expect(result).toEqual({ ok: false, @@ -96,7 +107,7 @@ describe("ScmCredentialsService", () => { .mockRejectedValue(new SourceControlProviderError("App not configured", "permanent")), }); - const result = await new ScmCredentialsService(provider, log).getCredentials(); + const result = await getCredentials(provider, log); expect(result).toEqual({ ok: false, @@ -120,7 +131,7 @@ describe("ScmCredentialsService", () => { .mockRejectedValue(new SourceControlProviderError("GitHub API unavailable", "transient")), }); - const result = await new ScmCredentialsService(provider, createTestLogger()).getCredentials(); + const result = await getCredentials(provider); expect(result).toEqual({ ok: false, status: 502, error: "GitHub API unavailable" }); }); @@ -131,7 +142,7 @@ describe("ScmCredentialsService", () => { generateCredentialHelperAuth: vi.fn().mockRejectedValue(new Error("network blew up")), }); - const result = await new ScmCredentialsService(provider, log).getCredentials(); + const result = await getCredentials(provider, log); expect(result).toEqual({ ok: false, @@ -140,4 +151,22 @@ describe("ScmCredentialsService", () => { }); expect(log.error).toHaveBeenCalled(); }); + + it("maps credential scope failures without minting credentials", async () => { + const provider = makeProvider(); + + const result = await getCredentials(provider, createTestLogger(), async () => { + throw new SourceControlProviderError( + "Cannot resolve credential scope: session not found", + "permanent" + ); + }); + + expect(result).toEqual({ + ok: false, + status: 500, + error: "Cannot resolve credential scope: session not found", + }); + expect(provider.generateCredentialHelperAuth).not.toHaveBeenCalled(); + }); }); diff --git a/packages/control-plane/src/session/scm-credentials-service.ts b/packages/control-plane/src/session/scm-credentials-service.ts index 3d254fe6c9..3fd4b8fa44 100644 --- a/packages/control-plane/src/session/scm-credentials-service.ts +++ b/packages/control-plane/src/session/scm-credentials-service.ts @@ -1,4 +1,4 @@ -import type { SourceControlProvider } from "../source-control"; +import type { CredentialScope, SourceControlProvider } from "../source-control"; import { SourceControlProviderError } from "../source-control/errors"; import type { Logger } from "../logger"; @@ -22,12 +22,14 @@ export type ScmCredentialsResult = export class ScmCredentialsService { constructor( private readonly provider: SourceControlProvider, - private readonly log: Logger + private readonly log: Logger, + private readonly resolveCredentialScope: () => Promise ) {} async getCredentials(): Promise { try { - const auth = await this.provider.generateCredentialHelperAuth(); + const scope = await this.resolveCredentialScope(); + const auth = await this.provider.generateCredentialHelperAuth(scope); if ( !auth.username.trim() || !auth.password.trim() || diff --git a/packages/control-plane/src/skills/git-import.test.ts b/packages/control-plane/src/skills/git-import.test.ts index e0595b08bb..379b66c15a 100644 --- a/packages/control-plane/src/skills/git-import.test.ts +++ b/packages/control-plane/src/skills/git-import.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { MAX_SKILL_FILE_BYTES, MAX_SKILL_REVISION_BYTES, @@ -102,6 +102,43 @@ const SKILL_MD = [ ].join("\n"); describe("fetchSkillImport", () => { + it("imports the workspace catalog with installation-wide content access", async () => { + const provider = fakeProvider({ "SKILL.md": { content: SKILL_MD } }); + const resolveCommit = vi.spyOn(provider, "resolveCommit"); + const listTree = vi.spyOn(provider, "listTree"); + const readBlob = vi.spyOn(provider, "readBlob"); + await fetchSkillImport(provider, source()); + for (const method of [resolveCommit, listTree, readBlob]) { + expect(method).toHaveBeenCalledWith(expect.any(Object), { kind: "all" }); + } + }); + + it("reuses the repository resolution already authorized by the route", async () => { + const provider = fakeProvider({ "SKILL.md": { content: SKILL_MD } }); + const check = vi.spyOn(provider, "checkRepositoryAccess"); + const resolveCommit = vi.spyOn(provider, "resolveCommit"); + const result = await fetchSkillImport(provider, source(), undefined, { + repoId: 123, + repoOwner: "canonical-owner", + repoName: "canonical-name", + defaultBranch: "develop", + }); + expect(check).not.toHaveBeenCalled(); + expect(resolveCommit).toHaveBeenCalledWith( + { + owner: "canonical-owner", + name: "canonical-name", + ref: "develop", + }, + { kind: "all" } + ); + expect(result.source).toMatchObject({ + repoOwner: "canonical-owner", + repoName: "canonical-name", + resolvedRef: "develop", + }); + }); + it("maps SKILL.md and supporting files onto a validated revision", async () => { const provider = fakeProvider({ "SKILL.md": { content: SKILL_MD }, @@ -213,6 +250,30 @@ describe("fetchSkillImport", () => { ]); }); + it("derives an unnamed skill from the requested alias while reading the canonical repository", async () => { + const provider = fakeProvider( + { "SKILL.md": { content: "---\ndescription: Deploys the API\n---\n# Deploy\n" } }, + { normalizedIdentity: { repoOwner: "canonical-owner", repoName: "renamed-skills" } } + ); + const resolveCommit = vi.spyOn(provider, "resolveCommit"); + const listTree = vi.spyOn(provider, "listTree"); + const readBlob = vi.spyOn(provider, "readBlob"); + + const result = await fetchSkillImport(provider, source()); + + expect(result.name).toBe("skills"); + expect(result.source).toMatchObject({ + repoOwner: "canonical-owner", + repoName: "renamed-skills", + }); + for (const read of [resolveCommit, listTree, readBlob]) { + expect(read).toHaveBeenCalledWith( + expect.objectContaining({ owner: "canonical-owner", name: "renamed-skills" }), + { kind: "all" } + ); + } + }); + it("surfaces frontmatter that has no managed-skill field", async () => { const provider = fakeProvider({ "SKILL.md": { diff --git a/packages/control-plane/src/skills/git-import.ts b/packages/control-plane/src/skills/git-import.ts index 47246676c0..028058e808 100644 --- a/packages/control-plane/src/skills/git-import.ts +++ b/packages/control-plane/src/skills/git-import.ts @@ -21,7 +21,11 @@ import { type SkillImportSourceInput, type SkillImportWarning, } from "@open-inspect/shared/types/skills"; -import type { RepositoryReader, RepositoryTreeEntry } from "../source-control"; +import type { + RepositoryAccessResult, + RepositoryReader, + RepositoryTreeEntry, +} from "../source-control"; import { SourceControlProviderError } from "../source-control"; import { buildValidatedSkillRevision, hashImportedSourceTree } from "./content-addressing"; import { parseSkillMarkdown, SkillMarkdownError, type ParsedSkillMarkdown } from "./skill-markdown"; @@ -122,12 +126,15 @@ async function readBlobs( async function worker(): Promise { for (let index = next++; index < entries.length; index = next++) { const entry = entries[index]; - const bytes = await provider.readBlob({ - owner: repository.owner, - name: repository.name, - blobId: entry.blobId, - maxBytes: MAX_SKILL_FILE_BYTES, - }); + const bytes = await provider.readBlob( + { + owner: repository.owner, + name: repository.name, + blobId: entry.blobId, + maxBytes: MAX_SKILL_FILE_BYTES, + }, + { kind: "all" } + ); const path = entry.path.slice(prefix.length); // The provider refuses an oversized blob before buffering when it can // tell the size up front. GitLab's tree carries no sizes, so this is the @@ -265,22 +272,19 @@ function resolveName( return parsed.data; } -/** - * Fetch, map, and validate one skill directory at a resolved commit. - * - * @param nameOverride - Canonical name to store under, overriding the source. - * @throws SkillImportError with the status the caller should return. - */ -export async function fetchSkillImport( +/** Resolve source access with the importer's HTTP error semantics, before reading content. */ +export async function resolveSkillImportRepository( provider: RepositoryReader, - source: SkillImportSourceInput, - nameOverride?: string | null -): Promise { - const repository = { owner: source.repository.repoOwner, name: source.repository.repoName }; - const label = `${repository.owner}/${repository.name}`; + source: SkillImportSourceInput +): Promise { + const requestedRepository = { + owner: source.repository.repoOwner, + name: source.repository.repoName, + }; + const label = `${requestedRepository.owner}/${requestedRepository.name}`; let access: Awaited>; try { - access = await provider.checkRepositoryAccess(repository); + access = await provider.checkRepositoryAccess(requestedRepository); } catch (error) { throw providerFailure(error, `Failed to reach ${label}`); } @@ -290,12 +294,30 @@ export async function fetchSkillImport( 404 ); } + return access; +} +/** + * Fetch, map, and validate one skill directory at a resolved commit. + * + * @param nameOverride - Canonical name to store under, overriding the source. + * @param resolvedRepository - Reuse the SCM identity already authorized by the route. + * @throws SkillImportError with the status the caller should return. + */ +export async function fetchSkillImport( + provider: RepositoryReader, + source: SkillImportSourceInput, + nameOverride?: string | null, + resolvedRepository?: RepositoryAccessResult +): Promise { + const label = `${source.repository.repoOwner}/${source.repository.repoName}`; + const access = resolvedRepository ?? (await resolveSkillImportRepository(provider, source)); + const repository = { owner: access.repoOwner, name: access.repoName }; const requestedRef = source.ref ?? null; const resolvedRef = requestedRef ?? access.defaultBranch; let commit: Awaited>; try { - commit = await provider.resolveCommit({ ...repository, ref: resolvedRef }); + commit = await provider.resolveCommit({ ...repository, ref: resolvedRef }, { kind: "all" }); } catch (error) { throw providerFailure(error, `Failed to resolve ${resolvedRef} in ${label}`); } @@ -305,11 +327,14 @@ export async function fetchSkillImport( let tree: Awaited>; try { - tree = await provider.listTree({ - ...repository, - commitSha: commit.sha, - path: source.subdirectory, - }); + tree = await provider.listTree( + { + ...repository, + commitSha: commit.sha, + path: source.subdirectory, + }, + { kind: "all" } + ); } catch (error) { throw providerFailure(error, `Failed to list ${label} at ${commit.sha}`); } @@ -392,7 +417,7 @@ export async function fetchSkillImport( nameOverride, mapped.frontmatterName, source.subdirectory ?? null, - repository.name, + source.repository.repoName, warnings ); const revision = await buildValidatedSkillRevision(name, mapped.content); diff --git a/packages/control-plane/src/source-control/credential-scope.test.ts b/packages/control-plane/src/source-control/credential-scope.test.ts new file mode 100644 index 0000000000..87edcf8e8f --- /dev/null +++ b/packages/control-plane/src/source-control/credential-scope.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { repositoryCredentialScope } from "./credential-scope"; +import { SourceControlProviderError } from "./errors"; + +describe("repositoryCredentialScope", () => { + it("sorts and de-duplicates the exact repository set", () => { + expect(repositoryCredentialScope([30, 2, 30])).toEqual({ + kind: "repositories", + repositoryIds: [2, 30], + }); + }); + + it("accepts 500 unique ids even when the input includes duplicates", () => { + const ids = Array.from({ length: 500 }, (_, i) => i + 1); + expect(repositoryCredentialScope([...ids, ...ids]).repositoryIds).toEqual(ids); + }); + + it("refuses more than 500 unique ids without truncating the set", () => { + expect(() => repositoryCredentialScope(Array.from({ length: 501 }, (_, i) => i + 1))).toThrow( + "500" + ); + }); + + it.each([[], [0], [-1], [1.5], [NaN], [Number.MAX_SAFE_INTEGER + 1]].map((ids) => ({ ids })))( + "refuses invalid or empty ids $ids with a permanent credential error", + ({ ids }) => { + try { + repositoryCredentialScope(ids); + expect.fail("Expected invalid repository scope to be refused"); + } catch (error) { + expect(error).toBeInstanceOf(SourceControlProviderError); + expect(error).toMatchObject({ errorType: "permanent" }); + } + } + ); +}); diff --git a/packages/control-plane/src/source-control/credential-scope.ts b/packages/control-plane/src/source-control/credential-scope.ts new file mode 100644 index 0000000000..a824f02e95 --- /dev/null +++ b/packages/control-plane/src/source-control/credential-scope.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; +import { SourceControlProviderError } from "./errors"; + +/** + * Per-call scope for deployment-level credentials; providers are shared across sessions. + * GitHub honors this scope when minting installation tokens. GitLab uses a + * deployment-wide PAT and does not narrow its credentials by scope. + * GitHub refuses an empty repository scope rather than granting installation-wide access. + */ +export type CredentialScope = { kind: "all" } | { kind: "repositories"; repositoryIds: number[] }; + +export const MAX_CREDENTIAL_SCOPE_REPOSITORY_IDS = 500; + +/** Canonical repository-only scope, including the GitHub installation-token limit. */ +export function repositoryCredentialScope( + repositoryIds: number[] +): Extract { + if (repositoryIds.length === 0) { + throw new SourceControlProviderError( + "Cannot generate credentials: no repositories in scope", + "permanent" + ); + } + if (!z.array(z.number().int().positive()).safeParse(repositoryIds).success) { + throw new SourceControlProviderError( + "Cannot generate credentials: invalid repository ids", + "permanent" + ); + } + const ids = [...new Set(repositoryIds)].sort((a, b) => a - b); + if (ids.length > MAX_CREDENTIAL_SCOPE_REPOSITORY_IDS) { + throw new SourceControlProviderError( + `Cannot generate credentials: scope exceeds ${MAX_CREDENTIAL_SCOPE_REPOSITORY_IDS} repositories`, + "permanent" + ); + } + return { kind: "repositories", repositoryIds: ids }; +} diff --git a/packages/control-plane/src/source-control/index.ts b/packages/control-plane/src/source-control/index.ts index 1248e7bd63..42ba03371b 100644 --- a/packages/control-plane/src/source-control/index.ts +++ b/packages/control-plane/src/source-control/index.ts @@ -10,6 +10,7 @@ export type { SourceControlProvider, SourceControlProviderName, SourceControlAuthContext, + CredentialScope, GitPushAuthContext, BuildManualPullRequestUrlConfig, BuildGitPushSpecConfig, diff --git a/packages/control-plane/src/source-control/provider-from-env.test.ts b/packages/control-plane/src/source-control/provider-from-env.test.ts index 6366cd4860..b4682d2179 100644 --- a/packages/control-plane/src/source-control/provider-from-env.test.ts +++ b/packages/control-plane/src/source-control/provider-from-env.test.ts @@ -29,7 +29,9 @@ describe("createSourceControlProviderFromEnv", () => { ); expect(provider).toBeInstanceOf(GitLabSourceControlProvider); - await expect(provider.generateCredentialHelperAuth()).resolves.toMatchObject({ + await expect( + provider.generateCredentialHelperAuth({ kind: "repositories", repositoryIds: [42] }) + ).resolves.toMatchObject({ username: "oauth2", password: "glpat-test", }); diff --git a/packages/control-plane/src/source-control/providers/github-provider.test.ts b/packages/control-plane/src/source-control/providers/github-provider.test.ts index 6576a2268c..a475c95e30 100644 --- a/packages/control-plane/src/source-control/providers/github-provider.test.ts +++ b/packages/control-plane/src/source-control/providers/github-provider.test.ts @@ -1,11 +1,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { GitHubSourceControlProvider } from "./github-provider"; import { SourceControlProviderError } from "../errors"; +import type { CredentialScope } from "../types"; // Mock the upstream GitHub App auth functions vi.mock("../../auth/github-app", () => ({ getCachedInstallationToken: vi.fn(), getCachedInstallationTokenWithExpiry: vi.fn(), + getInstallationTokenCacheKey: vi.fn(), + invalidateInstallationTokenCache: vi.fn(), getInstallationRepository: vi.fn(), listInstallationRepositories: vi.fn(), fetchWithTimeout: vi.fn(), @@ -15,6 +18,8 @@ import { fetchWithTimeout, getCachedInstallationToken, getCachedInstallationTokenWithExpiry, + getInstallationTokenCacheKey, + invalidateInstallationTokenCache, getInstallationRepository, listInstallationRepositories, } from "../../auth/github-app"; @@ -23,6 +28,8 @@ const mockGetInstallationRepository = vi.mocked(getInstallationRepository); const mockListInstallationRepositories = vi.mocked(listInstallationRepositories); const mockGetCachedInstallationTokenWithExpiry = vi.mocked(getCachedInstallationTokenWithExpiry); const mockGetCachedInstallationToken = vi.mocked(getCachedInstallationToken); +const mockGetInstallationTokenCacheKey = vi.mocked(getInstallationTokenCacheKey); +const mockInvalidateInstallationTokenCache = vi.mocked(invalidateInstallationTokenCache); const mockFetchWithTimeout = vi.mocked(fetchWithTimeout); function makeResponse(body: unknown, status = 200): Response { @@ -50,6 +57,8 @@ const fakeAppConfig = { installationId: "456", }; +const repositoryScope: CredentialScope = { kind: "repositories", repositoryIds: [9001] }; + describe("GitHubSourceControlProvider", () => { beforeEach(() => { vi.clearAllMocks(); @@ -64,7 +73,10 @@ describe("GitHubSourceControlProvider", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); await expect( - provider.getBranchHead({ owner: "acme", name: "web", branch: "feature/test" }) + provider.getBranchHead( + { owner: "acme", name: "web", branch: "feature/test" }, + repositoryScope + ) ).resolves.toBe("abc123"); expect(mockFetchWithTimeout).toHaveBeenCalledWith( expect.stringContaining("heads/feature%2Ftest"), @@ -78,7 +90,7 @@ describe("GitHubSourceControlProvider", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); await expect( - provider.getBranchHead({ owner: "acme", name: "web", branch: "missing" }) + provider.getBranchHead({ owner: "acme", name: "web", branch: "missing" }, repositoryScope) ).resolves.toBeNull(); }); @@ -88,7 +100,7 @@ describe("GitHubSourceControlProvider", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); const err = await provider - .getBranchHead({ owner: "acme", name: "web", branch: "main" }) + .getBranchHead({ owner: "acme", name: "web", branch: "main" }, repositoryScope) .catch((e: unknown) => e); expect(err).toBeInstanceOf(SourceControlProviderError); @@ -459,7 +471,9 @@ describe("GitHubSourceControlProvider", () => { describe("generateCredentialHelperAuth", () => { it("throws a permanent error when the App is not configured", async () => { const provider = new GitHubSourceControlProvider(); - const err = await provider.generateCredentialHelperAuth().catch((e: unknown) => e); + const err = await provider + .generateCredentialHelperAuth(repositoryScope) + .catch((e: unknown) => e); expect(err).toBeInstanceOf(SourceControlProviderError); expect((err as SourceControlProviderError).errorType).toBe("permanent"); @@ -474,7 +488,7 @@ describe("GitHubSourceControlProvider", () => { }); const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); - const auth = await provider.generateCredentialHelperAuth(); + const auth = await provider.generateCredentialHelperAuth(repositoryScope); expect(auth).toEqual({ username: "x-access-token", @@ -483,7 +497,8 @@ describe("GitHubSourceControlProvider", () => { }); expect(mockGetCachedInstallationTokenWithExpiry).toHaveBeenCalledWith( fakeAppConfig, - expect.objectContaining({ userAgent: expect.any(String) }) + expect.objectContaining({ userAgent: expect.any(String) }), + { scope: repositoryScope } ); }); @@ -491,7 +506,9 @@ describe("GitHubSourceControlProvider", () => { mockGetCachedInstallationTokenWithExpiry.mockRejectedValueOnce(new Error("GitHub 500")); const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); - const err = await provider.generateCredentialHelperAuth().catch((e: unknown) => e); + const err = await provider + .generateCredentialHelperAuth(repositoryScope) + .catch((e: unknown) => e); expect(err).toBeInstanceOf(SourceControlProviderError); expect((err as SourceControlProviderError).message).toContain("GitHub 500"); @@ -504,7 +521,9 @@ describe("GitHubSourceControlProvider", () => { mockGetCachedInstallationTokenWithExpiry.mockRejectedValueOnce(httpError); const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); - const err = await provider.generateCredentialHelperAuth().catch((e: unknown) => e); + const err = await provider + .generateCredentialHelperAuth(repositoryScope) + .catch((e: unknown) => e); expect(err).toBeInstanceOf(SourceControlProviderError); // Transient → the service maps this to 502, not 500. @@ -780,7 +799,7 @@ describe("getPullRequest", () => { it("throws a permanent error when the App is not configured", async () => { const provider = new GitHubSourceControlProvider(); const err = await provider - .getPullRequest({ owner: "acme", name: "web", number: 7 }) + .getPullRequest({ owner: "acme", name: "web", number: 7 }, repositoryScope) .catch((e: unknown) => e); expect(err).toBeInstanceOf(SourceControlProviderError); @@ -793,7 +812,10 @@ describe("getPullRequest", () => { ); const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); - const snapshot = await provider.getPullRequest({ owner: "acme", name: "web", number: 7 }); + const snapshot = await provider.getPullRequest( + { owner: "acme", name: "web", number: 7 }, + repositoryScope + ); expect(snapshot).toEqual({ number: 7, @@ -824,7 +846,10 @@ describe("getPullRequest", () => { ); const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); - const snapshot = await provider.getPullRequest({ owner: "acme", name: "web", number: 7 }); + const snapshot = await provider.getPullRequest( + { owner: "acme", name: "web", number: 7 }, + repositoryScope + ); expect(snapshot.lifecycleState).toBe("merged"); expect(snapshot.isDraft).toBe(false); @@ -836,7 +861,10 @@ describe("getPullRequest", () => { ); const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); - const snapshot = await provider.getPullRequest({ owner: "acme", name: "web", number: 7 }); + const snapshot = await provider.getPullRequest( + { owner: "acme", name: "web", number: 7 }, + repositoryScope + ); expect(snapshot.lifecycleState).toBe("closed"); }); @@ -854,7 +882,10 @@ describe("getPullRequest", () => { ); const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); - const snapshot = await provider.getPullRequest({ owner: "acme", name: "web", number: 7 }); + const snapshot = await provider.getPullRequest( + { owner: "acme", name: "web", number: 7 }, + repositoryScope + ); expect(snapshot.providerCreatedAt).toBe(Date.parse("2026-07-08T09:00:00Z")); expect(snapshot.mergedAt).toBe(Date.parse("2026-07-10T12:00:00Z")); @@ -872,7 +903,10 @@ describe("getPullRequest", () => { ); const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); - const snapshot = await provider.getPullRequest({ owner: "acme", name: "web", number: 7 }); + const snapshot = await provider.getPullRequest( + { owner: "acme", name: "web", number: 7 }, + repositoryScope + ); expect(snapshot.providerCreatedAt).toBe(Date.parse("2026-07-08T09:00:00Z")); expect(snapshot.mergedAt).toBeUndefined(); @@ -907,12 +941,10 @@ describe("getPullRequest", () => { ); const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); - const snapshot = await provider.getPullRequest({ - owner: "acme", - name: "web", - number: 7, - repositoryExternalId: "9001", - }); + const snapshot = await provider.getPullRequest( + { owner: "acme", name: "web", number: 7, repositoryExternalId: "9001" }, + repositoryScope + ); expect(snapshot.repoName).toBe("web-renamed"); expect(mockFetchWithTimeout).toHaveBeenNthCalledWith( @@ -932,7 +964,7 @@ describe("getPullRequest", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); const err = await provider - .getPullRequest({ owner: "acme", name: "web", number: 7 }) + .getPullRequest({ owner: "acme", name: "web", number: 7 }, repositoryScope) .catch((e: unknown) => e); expect(err).toBeInstanceOf(SourceControlProviderError); @@ -947,7 +979,10 @@ describe("getPullRequest", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); const err = await provider - .getPullRequest({ owner: "acme", name: "web", number: 7, repositoryExternalId: "9001" }) + .getPullRequest( + { owner: "acme", name: "web", number: 7, repositoryExternalId: "9001" }, + repositoryScope + ) .catch((e: unknown) => e); expect(err).toBeInstanceOf(SourceControlProviderError); @@ -974,12 +1009,15 @@ describe("getPullRequestFeedback", () => { ); const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); - const feedback = await provider.getPullRequestFeedback({ - owner: "acme", - name: "web", - pullRequestNumber: 7, - providerObject: { kind: "pr_comment", id: "1234" }, - }); + const feedback = await provider.getPullRequestFeedback( + { + owner: "acme", + name: "web", + pullRequestNumber: 7, + providerObject: { kind: "pr_comment", id: "1234" }, + }, + repositoryScope + ); expect(feedback).toEqual({ kind: "pr_comment", @@ -1009,12 +1047,15 @@ describe("getPullRequestFeedback", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); await expect( - provider.getPullRequestFeedback({ - owner: "acme", - name: "web", - pullRequestNumber: 7, - providerObject: { kind: "pr_comment", id: "1234" }, - }) + provider.getPullRequestFeedback( + { + owner: "acme", + name: "web", + pullRequestNumber: 7, + providerObject: { kind: "pr_comment", id: "1234" }, + }, + repositoryScope + ) ).rejects.toMatchObject({ errorType: "permanent", message: "Pull request comment does not belong to the requested pull request", @@ -1065,12 +1106,15 @@ describe("getPullRequestFeedback", () => { ); const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); - const feedback = await provider.getPullRequestFeedback({ - owner: "acme", - name: "web", - pullRequestNumber: 7, - providerObject: { kind: "review", id: "5678" }, - }); + const feedback = await provider.getPullRequestFeedback( + { + owner: "acme", + name: "web", + pullRequestNumber: 7, + providerObject: { kind: "review", id: "5678" }, + }, + repositoryScope + ); expect(feedback).toMatchObject({ kind: "review", @@ -1121,12 +1165,15 @@ describe("getPullRequestFeedback", () => { .mockResolvedValueOnce(makeJsonResponse([])); const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); - const feedback = await provider.getPullRequestFeedback({ - owner: "acme", - name: "web", - pullRequestNumber: 7, - providerObject: { kind: "review", id: "5678" }, - }); + const feedback = await provider.getPullRequestFeedback( + { + owner: "acme", + name: "web", + pullRequestNumber: 7, + providerObject: { kind: "review", id: "5678" }, + }, + repositoryScope + ); expect(feedback.kind === "review" ? feedback.comments : []).toHaveLength(100); expect(mockFetchWithTimeout).toHaveBeenNthCalledWith( @@ -1150,12 +1197,15 @@ describe("getPullRequestFeedback", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); await expect( - provider.getPullRequestFeedback({ - owner: "acme", - name: "web", - pullRequestNumber: 7, - providerObject: { kind: "review", id: "5678" }, - }) + provider.getPullRequestFeedback( + { + owner: "acme", + name: "web", + pullRequestNumber: 7, + providerObject: { kind: "review", id: "5678" }, + }, + repositoryScope + ) ).rejects.toMatchObject({ errorType: "permanent", message: "Pull request review does not belong to the requested pull request", @@ -1181,12 +1231,15 @@ describe("getPullRequestFeedback", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); const error = await provider - .getPullRequestFeedback({ - owner: "acme", - name: "web", - pullRequestNumber: 7, - providerObject: { kind: "review", id: "5678" }, - }) + .getPullRequestFeedback( + { + owner: "acme", + name: "web", + pullRequestNumber: 7, + providerObject: { kind: "review", id: "5678" }, + }, + repositoryScope + ) .catch((caught: unknown) => caught); expect(error).toBeInstanceOf(SourceControlProviderError); @@ -1208,11 +1261,10 @@ describe("hasPullRequestWritePermission", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); await expect( - provider.hasPullRequestWritePermission({ - owner: "acme", - name: "web", - authorLogin: "alice", - }) + provider.hasPullRequestWritePermission( + { owner: "acme", name: "web", authorLogin: "alice" }, + repositoryScope + ) ).resolves.toBe(true); expect(mockFetchWithTimeout).toHaveBeenCalledWith( "https://api.github.com/repos/acme/web/collaborators/alice/permission", @@ -1228,11 +1280,10 @@ describe("hasPullRequestWritePermission", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); await expect( - provider.hasPullRequestWritePermission({ - owner: "acme", - name: "web", - authorLogin: "alice", - }) + provider.hasPullRequestWritePermission( + { owner: "acme", name: "web", authorLogin: "alice" }, + repositoryScope + ) ).resolves.toBe(false); } ); @@ -1242,11 +1293,10 @@ describe("hasPullRequestWritePermission", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); await expect( - provider.hasPullRequestWritePermission({ - owner: "acme", - name: "web", - authorLogin: "alice", - }) + provider.hasPullRequestWritePermission( + { owner: "acme", name: "web", authorLogin: "alice" }, + repositoryScope + ) ).resolves.toBe(false); }); @@ -1254,11 +1304,10 @@ describe("hasPullRequestWritePermission", () => { mockFetchWithTimeout.mockResolvedValueOnce(makeJsonResponse({ permission: "write" })); const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); - await provider.hasPullRequestWritePermission({ - owner: "acme org", - name: "web api", - authorLogin: "alice/bob", - }); + await provider.hasPullRequestWritePermission( + { owner: "acme org", name: "web api", authorLogin: "alice/bob" }, + repositoryScope + ); expect(mockFetchWithTimeout).toHaveBeenCalledWith( "https://api.github.com/repos/acme%20org/web%20api/collaborators/alice%2Fbob/permission", @@ -1354,7 +1403,7 @@ describe("response validation (zod boundary)", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); const err = await provider - .getPullRequest({ owner: "acme", name: "web", number: 7 }) + .getPullRequest({ owner: "acme", name: "web", number: 7 }, repositoryScope) .catch((e: unknown) => e); expect(err).toBeInstanceOf(SourceControlProviderError); @@ -1369,7 +1418,7 @@ describe("response validation (zod boundary)", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); const err = await provider - .getPullRequest({ owner: "acme", name: "web", number: 7 }) + .getPullRequest({ owner: "acme", name: "web", number: 7 }, repositoryScope) .catch((e: unknown) => e); expect(err).toBeInstanceOf(SourceControlProviderError); @@ -1386,7 +1435,7 @@ describe("response validation (zod boundary)", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); const err = await provider - .getPullRequest({ owner: "acme", name: "web", number: 7 }) + .getPullRequest({ owner: "acme", name: "web", number: 7 }, repositoryScope) .catch((e: unknown) => e); expect(err).toBeInstanceOf(SourceControlProviderError); @@ -1400,7 +1449,10 @@ describe("response validation (zod boundary)", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); const err = await provider - .getPullRequest({ owner: "acme", name: "web", number: 7, repositoryExternalId: "9001" }) + .getPullRequest( + { owner: "acme", name: "web", number: 7, repositoryExternalId: "9001" }, + repositoryScope + ) .catch((e: unknown) => e); expect(err).toBeInstanceOf(SourceControlProviderError); @@ -1450,7 +1502,10 @@ describe("managed-skill repository reads", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); await expect( - provider.resolveCommit({ owner: "acme", name: "skills", ref: "feature/test" }) + provider.resolveCommit( + { owner: "acme", name: "skills", ref: "feature/test" }, + repositoryScope + ) ).resolves.toEqual({ sha: "abc123" }); expect(mockFetchWithTimeout).toHaveBeenCalledWith( expect.stringContaining("commits/feature%2Ftest"), @@ -1465,7 +1520,7 @@ describe("managed-skill repository reads", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); await expect( - provider.resolveCommit({ owner: "acme", name: "skills", ref: "missing" }) + provider.resolveCommit({ owner: "acme", name: "skills", ref: "missing" }, repositoryScope) ).resolves.toBeNull(); }); @@ -1482,7 +1537,10 @@ describe("managed-skill repository reads", () => { ); const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); - const tree = await provider.listTree({ owner: "acme", name: "skills", commitSha: "abc" }); + const tree = await provider.listTree( + { owner: "acme", name: "skills", commitSha: "abc" }, + repositoryScope + ); expect(tree.entries.map(({ type, executable }) => ({ type, executable }))).toEqual([ { type: "file", executable: false }, @@ -1511,12 +1569,10 @@ describe("managed-skill repository reads", () => { ); const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); - const tree = await provider.listTree({ - owner: "acme", - name: "skills", - commitSha: "abc", - path: "skills/deploy", - }); + const tree = await provider.listTree( + { owner: "acme", name: "skills", commitSha: "abc", path: "skills/deploy" }, + repositoryScope + ); expect(tree.entries[0]?.path).toBe("skills/deploy/SKILL.md"); expect(mockFetchWithTimeout.mock.calls.map(([url]) => String(url))).toEqual([ @@ -1531,7 +1587,10 @@ describe("managed-skill repository reads", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); await expect( - provider.listTree({ owner: "acme", name: "skills", commitSha: "abc", path: "missing" }) + provider.listTree( + { owner: "acme", name: "skills", commitSha: "abc", path: "missing" }, + repositoryScope + ) ).resolves.toEqual({ entries: [], truncated: false }); expect(mockFetchWithTimeout).toHaveBeenCalledTimes(1); }); @@ -1551,7 +1610,7 @@ describe("managed-skill repository reads", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); const error = await provider - .readBlob({ owner: "acme", name: "skills", blobId: "big", maxBytes: 4 }) + .readBlob({ owner: "acme", name: "skills", blobId: "big", maxBytes: 4 }, repositoryScope) .catch((thrown: unknown) => thrown); expect(error).toBeInstanceOf(SourceControlProviderError); @@ -1559,3 +1618,90 @@ describe("managed-skill repository reads", () => { expect(cancelled).toBe(true); }); }); + +describe("credential scopes", () => { + beforeEach(() => { + vi.resetAllMocks(); + mockGetCachedInstallationToken.mockResolvedValue("installation-token"); + mockGetInstallationTokenCacheKey.mockResolvedValue("scoped-cache-key"); + }); + + it("uses each call's scope on a shared provider without retaining prior scopes", async () => { + const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); + const scopes: CredentialScope[] = [ + repositoryScope, + { kind: "all" }, + { kind: "repositories", repositoryIds: [42, 99] }, + ]; + + for (const scope of scopes) { + await provider.generatePushAuth(scope); + } + + expect(mockGetCachedInstallationToken.mock.calls.map(([, , options]) => options)).toEqual( + scopes.map((scope) => ({ scope })) + ); + }); + + it("invalidates the scoped cache key and retries a 401 once with a refreshed token", async () => { + const env = { cacheStore: { get: vi.fn(), put: vi.fn(), delete: vi.fn() }, userAgent: "Bot" }; + const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig, ...env }); + mockGetCachedInstallationToken + .mockResolvedValueOnce("expired-token") + .mockResolvedValueOnce("refreshed-token"); + mockFetchWithTimeout + .mockResolvedValueOnce(makeJsonResponse({ message: "Bad credentials" }, 401)) + .mockResolvedValueOnce(makeJsonResponse({ object: { sha: "abc123" } })); + + await expect( + provider.getBranchHead({ owner: "acme", name: "web", branch: "main" }, repositoryScope) + ).resolves.toBe("abc123"); + + expect(mockGetInstallationTokenCacheKey).toHaveBeenCalledWith(fakeAppConfig, repositoryScope); + expect(mockInvalidateInstallationTokenCache).toHaveBeenCalledWith(env, "scoped-cache-key"); + expect(mockGetCachedInstallationToken.mock.calls.map(([, , options]) => options)).toEqual([ + { scope: repositoryScope }, + { scope: repositoryScope, forceRefresh: true }, + ]); + expect(mockFetchWithTimeout.mock.calls[1][1]?.headers).toMatchObject({ + Authorization: "Bearer refreshed-token", + }); + }); + + it("surfaces a second 401 without retrying again", async () => { + mockFetchWithTimeout + .mockResolvedValueOnce(new Response("expired", { status: 401 })) + .mockResolvedValueOnce(new Response("still expired", { status: 401 })); + const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); + + await expect( + provider.resolveCommit({ owner: "acme", name: "web", ref: "main" }, repositoryScope) + ).rejects.toMatchObject({ httpStatus: 401, errorType: "permanent" }); + expect(mockFetchWithTimeout).toHaveBeenCalledTimes(2); + }); + + it("surfaces a 403 without refreshing credentials", async () => { + mockFetchWithTimeout.mockResolvedValueOnce(makeJsonResponse({ message: "Forbidden" }, 403)); + const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); + + await expect( + provider.listTree({ owner: "acme", name: "web", commitSha: "abc123" }, repositoryScope) + ).rejects.toMatchObject({ httpStatus: 403, errorType: "permanent" }); + expect(mockInvalidateInstallationTokenCache).not.toHaveBeenCalled(); + }); + + it("surfaces token refresh failures as provider errors", async () => { + mockFetchWithTimeout.mockResolvedValueOnce(new Response("expired", { status: 401 })); + mockGetCachedInstallationToken + .mockResolvedValueOnce("expired-token") + .mockRejectedValueOnce( + Object.assign(new Error("token service unavailable"), { status: 502 }) + ); + const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); + + await expect( + provider.getPullRequest({ owner: "acme", name: "web", number: 7 }, repositoryScope) + ).rejects.toMatchObject({ httpStatus: 502, errorType: "transient" }); + expect(mockFetchWithTimeout).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/control-plane/src/source-control/providers/github-provider.ts b/packages/control-plane/src/source-control/providers/github-provider.ts index 1594a1cdac..ba71e17ade 100644 --- a/packages/control-plane/src/source-control/providers/github-provider.ts +++ b/packages/control-plane/src/source-control/providers/github-provider.ts @@ -24,6 +24,7 @@ import type { GitPushSpec, GitPushAuthContext, CredentialHelperAuth, + CredentialScope, ResolvedCommit, RepositoryTree, } from "../types"; @@ -36,6 +37,8 @@ import { classifyGitTreeEntry } from "./git-tree"; import { getCachedInstallationToken, getCachedInstallationTokenWithExpiry, + getInstallationTokenCacheKey, + invalidateInstallationTokenCache, getInstallationRepository, listInstallationRepositories, listRepositoryBranches, @@ -271,10 +274,11 @@ export class GitHubSourceControlProvider implements SourceControlProvider { } async getPullRequestFeedback( - config: GetGitHubPullRequestFeedbackConfig + config: GetGitHubPullRequestFeedbackConfig, + scope: CredentialScope ): Promise { if (config.providerObject.kind === "review") { - return this.getPullRequestReviewFeedback(config, config.providerObject.id); + return this.getPullRequestReviewFeedback(config, config.providerObject.id, scope); } const repositoryPath = `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent( @@ -282,6 +286,7 @@ export class GitHubSourceControlProvider implements SourceControlProvider { )}`; const data = await this.appJsonRequired( `${repositoryPath}/issues/comments/${encodeURIComponent(config.providerObject.id)}`, + scope, githubPullRequestCommentSchema, "get pull request comment" ); @@ -309,15 +314,19 @@ export class GitHubSourceControlProvider implements SourceControlProvider { }; } - async hasPullRequestWritePermission(config: { - owner: string; - name: string; - authorLogin: string; - }): Promise { + async hasPullRequestWritePermission( + config: { + owner: string; + name: string; + authorLogin: string; + }, + scope: CredentialScope + ): Promise { const data = await this.appJson( `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent( config.name )}/collaborators/${encodeURIComponent(config.authorLogin)}/permission`, + scope, githubCollaboratorPermissionSchema, "get collaborator permission", true @@ -329,7 +338,8 @@ export class GitHubSourceControlProvider implements SourceControlProvider { private async getPullRequestReviewFeedback( config: GitHubPullRequestFeedbackLocation, - reviewId: string + reviewId: string, + scope: CredentialScope ): Promise> { const pullRequestPath = `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent( config.name @@ -337,6 +347,7 @@ export class GitHubSourceControlProvider implements SourceControlProvider { const reviewPath = `${pullRequestPath}/reviews/${encodeURIComponent(reviewId)}`; const review = await this.appJsonRequired( reviewPath, + scope, githubPullRequestReviewSchema, "get pull request review" ); @@ -354,6 +365,7 @@ export class GitHubSourceControlProvider implements SourceControlProvider { for (let page = 1; ; page += 1) { const pageComments = await this.appJsonRequired( `${reviewPath}/comments?per_page=${GITHUB_REVIEW_COMMENTS_PER_PAGE}&page=${page}`, + scope, z.array(githubReviewCommentSchema), "get pull request review comments" ); @@ -541,7 +553,10 @@ export class GitHubSourceControlProvider implements SourceControlProvider { * On a 404 with a known stable repo id, re-resolves the repository's * current owner/name by id and retries once (rename/transfer tolerance). */ - async getPullRequest(config: GetPullRequestConfig): Promise { + async getPullRequest( + config: GetPullRequestConfig, + scope: CredentialScope + ): Promise { if (!this.appConfig) { throw new SourceControlProviderError( "GitHub App not configured - cannot get pull request", @@ -549,26 +564,12 @@ export class GitHubSourceControlProvider implements SourceControlProvider { ); } - let token: string; - try { - token = await getCachedInstallationToken(this.appConfig, { - cacheStore: this.cacheStore, - userAgent: this.userAgent, - }); - } catch (error) { - throw SourceControlProviderError.fromFetchError( - `Failed to generate GitHub App token: ${error instanceof Error ? error.message : String(error)}`, - error, - extractHttpStatus(error) - ); - } - - let response = await this.fetchPullRequest(token, config.owner, config.name, config.number); + let response = await this.fetchPullRequest(scope, config.owner, config.name, config.number); if (response.status === 404 && config.repositoryExternalId) { - const resolved = await this.resolveRepositoryLocationById(token, config.repositoryExternalId); + const resolved = await this.resolveRepositoryLocationById(scope, config.repositoryExternalId); if (resolved) { - response = await this.fetchPullRequest(token, resolved.owner, resolved.name, config.number); + response = await this.fetchPullRequest(scope, resolved.owner, resolved.name, config.number); } } @@ -612,18 +613,17 @@ export class GitHubSourceControlProvider implements SourceControlProvider { } private fetchPullRequest( - token: string, + scope: CredentialScope, owner: string, name: string, number: number ): Promise { - return fetchWithTimeout(`${GITHUB_API_BASE}/repos/${owner}/${name}/pulls/${number}`, { - headers: { - Accept: "application/vnd.github.v3+json", - Authorization: `Bearer ${token}`, - "User-Agent": this.userAgent, - }, - }); + return this.appFetch( + `/repos/${owner}/${name}/pulls/${number}`, + scope, + "get pull request", + "application/vnd.github.v3+json" + ); } /** @@ -636,18 +636,14 @@ export class GitHubSourceControlProvider implements SourceControlProvider { * and the caller surfaces the original 404. */ private async resolveRepositoryLocationById( - token: string, + scope: CredentialScope, repositoryExternalId: string ): Promise<{ owner: string; name: string } | null> { - const response = await fetchWithTimeout( - `${GITHUB_API_BASE}/repositories/${encodeURIComponent(repositoryExternalId)}`, - { - headers: { - Accept: "application/vnd.github.v3+json", - Authorization: `Bearer ${token}`, - "User-Agent": this.userAgent, - }, - } + const response = await this.appFetch( + `/repositories/${encodeURIComponent(repositoryExternalId)}`, + scope, + "resolve repository location", + "application/vnd.github.v3+json" ); if (!response.ok) { @@ -753,7 +749,10 @@ export class GitHubSourceControlProvider implements SourceControlProvider { } } - async getBranchHead(config: GetRepositoryConfig & { branch: string }): Promise { + async getBranchHead( + config: GetRepositoryConfig & { branch: string }, + scope: CredentialScope + ): Promise { if (!this.appConfig) { throw new SourceControlProviderError( "GitHub App not configured - cannot resolve branch head", @@ -761,21 +760,13 @@ export class GitHubSourceControlProvider implements SourceControlProvider { ); } try { - const token = await getCachedInstallationToken(this.appConfig, { - cacheStore: this.cacheStore, - userAgent: this.userAgent, - }); - const response = await fetchWithTimeout( - `${GITHUB_API_BASE}/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent( + const response = await this.appFetch( + `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent( config.name )}/git/ref/heads/${encodeURIComponent(config.branch)}`, - { - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${token}`, - "User-Agent": this.userAgent, - }, - } + scope, + "resolve branch head", + "application/vnd.github+json" ); if (response.status === 404) return null; if (!response.ok) { @@ -803,13 +794,15 @@ export class GitHubSourceControlProvider implements SourceControlProvider { } async resolveCommit( - config: GetRepositoryConfig & { ref: string } + config: GetRepositoryConfig & { ref: string }, + scope: CredentialScope ): Promise { try { const response = await this.appFetch( `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent( config.name )}/commits/${encodeURIComponent(config.ref)}`, + scope, "resolve commit", "application/vnd.github.sha" ); @@ -829,7 +822,8 @@ export class GitHubSourceControlProvider implements SourceControlProvider { } async listTree( - config: GetRepositoryConfig & { commitSha: string; path?: string | null } + config: GetRepositoryConfig & { commitSha: string; path?: string | null }, + scope: CredentialScope ): Promise { const scopedPath = config.path?.trim() || null; let treeSha = config.commitSha; @@ -839,6 +833,7 @@ export class GitHubSourceControlProvider implements SourceControlProvider { `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent( config.name )}/git/trees/${encodeURIComponent(treeSha)}`, + scope, githubTreeSchema, "resolve repository subtree" ); @@ -851,6 +846,7 @@ export class GitHubSourceControlProvider implements SourceControlProvider { `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent( config.name )}/git/trees/${encodeURIComponent(treeSha)}?recursive=1`, + scope, githubTreeSchema, "list repository tree" ); @@ -870,13 +866,15 @@ export class GitHubSourceControlProvider implements SourceControlProvider { } async readBlob( - config: GetRepositoryConfig & { blobId: string; maxBytes: number } + config: GetRepositoryConfig & { blobId: string; maxBytes: number }, + scope: CredentialScope ): Promise { try { const response = await this.appFetch( `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent( config.name )}/git/blobs/${encodeURIComponent(config.blobId)}`, + scope, "read blob", "application/vnd.github.raw" ); @@ -893,24 +891,52 @@ export class GitHubSourceControlProvider implements SourceControlProvider { } /** Issue an installation-authenticated GitHub API request. */ - private async appFetch(path: string, operation: string, accept: string): Promise { + private async appFetch( + path: string, + scope: CredentialScope, + operation: string, + accept: string + ): Promise { if (!this.appConfig) { throw new SourceControlProviderError( `GitHub App not configured - cannot ${operation}`, "permanent" ); } - const token = await getCachedInstallationToken(this.appConfig, { + const env = { cacheStore: this.cacheStore, userAgent: this.userAgent, - }); - return fetchWithTimeout(`${GITHUB_API_BASE}${path}`, { - headers: { + }; + try { + const token = await getCachedInstallationToken(this.appConfig, env, { scope }); + const headers = { Accept: accept, Authorization: `Bearer ${token}`, "User-Agent": this.userAgent, - }, - }); + }; + let response = await fetchWithTimeout(`${GITHUB_API_BASE}${path}`, { headers }); + if (response.status === 401) { + await invalidateInstallationTokenCache( + env, + await getInstallationTokenCacheKey(this.appConfig, scope) + ); + const refreshedToken = await getCachedInstallationToken(this.appConfig, env, { + scope, + forceRefresh: true, + }); + response = await fetchWithTimeout(`${GITHUB_API_BASE}${path}`, { + headers: { ...headers, Authorization: `Bearer ${refreshedToken}` }, + }); + } + return response; + } catch (error) { + if (error instanceof SourceControlProviderError) throw error; + throw SourceControlProviderError.fromFetchError( + `Failed to ${operation}: ${error instanceof Error ? error.message : String(error)}`, + error, + extractHttpStatus(error) + ); + } } /** @@ -919,12 +945,13 @@ export class GitHubSourceControlProvider implements SourceControlProvider { */ private async appJson( path: string, + scope: CredentialScope, schema: z.ZodType, operation: string, notFoundIsAbsence: boolean ): Promise { try { - const response = await this.appFetch(path, operation, "application/vnd.github+json"); + const response = await this.appFetch(path, scope, operation, "application/vnd.github+json"); if (notFoundIsAbsence && response.status === 404) return null; if (!response.ok) throw await githubResponseError(response, operation); return await parseProviderResponse(response, schema, `Failed to ${operation}`); @@ -940,10 +967,11 @@ export class GitHubSourceControlProvider implements SourceControlProvider { private async appJsonRequired( path: string, + scope: CredentialScope, schema: z.ZodType, operation: string ): Promise { - const data = await this.appJson(path, schema, operation, false); + const data = await this.appJson(path, scope, schema, operation, false); if (data === null) throw new SourceControlProviderError(`Failed to ${operation}`, "permanent"); return data; } @@ -951,7 +979,7 @@ export class GitHubSourceControlProvider implements SourceControlProvider { /** * Generate authentication for git push operations using GitHub App. */ - async generatePushAuth(): Promise { + async generatePushAuth(scope: CredentialScope): Promise { if (!this.appConfig) { throw new SourceControlProviderError( "GitHub App not configured - cannot generate push auth", @@ -960,10 +988,11 @@ export class GitHubSourceControlProvider implements SourceControlProvider { } try { - const token = await getCachedInstallationToken(this.appConfig, { - cacheStore: this.cacheStore, - userAgent: this.userAgent, - }); + const token = await getCachedInstallationToken( + this.appConfig, + { cacheStore: this.cacheStore, userAgent: this.userAgent }, + { scope } + ); return { authType: "app", token, @@ -976,7 +1005,7 @@ export class GitHubSourceControlProvider implements SourceControlProvider { } } - async generateCredentialHelperAuth(): Promise { + async generateCredentialHelperAuth(scope: CredentialScope): Promise { if (!this.appConfig) { throw new SourceControlProviderError( "GitHub App not configured - cannot generate credential helper auth", @@ -990,7 +1019,8 @@ export class GitHubSourceControlProvider implements SourceControlProvider { { cacheStore: this.cacheStore, userAgent: this.userAgent, - } + }, + { scope } ); return { username: "x-access-token", diff --git a/packages/control-plane/src/source-control/providers/gitlab-provider.test.ts b/packages/control-plane/src/source-control/providers/gitlab-provider.test.ts index e5677f4d50..f1c659ca81 100644 --- a/packages/control-plane/src/source-control/providers/gitlab-provider.test.ts +++ b/packages/control-plane/src/source-control/providers/gitlab-provider.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { GitLabSourceControlProvider, deriveGitLabMergeRequestStatus } from "./gitlab-provider"; import { SourceControlProviderError } from "../errors"; +import type { CredentialScope } from "../types"; // Mock global fetch const mockFetch = vi.fn(); @@ -17,6 +18,7 @@ function makeResponse(body: unknown, status = 200, headers: HeadersInit = {}): R } const fakeConfig = { accessToken: "glpat-test-token" }; +const repositoryScope: CredentialScope = { kind: "repositories", repositoryIds: [9001] }; describe("GitLabSourceControlProvider", () => { beforeEach(() => { @@ -29,11 +31,10 @@ describe("GitLabSourceControlProvider", () => { const provider = new GitLabSourceControlProvider(fakeConfig); await expect( - provider.getBranchHead({ - owner: "acme/platform", - name: "web", - branch: "feature/test", - }) + provider.getBranchHead( + { owner: "acme/platform", name: "web", branch: "feature/test" }, + repositoryScope + ) ).resolves.toBe("def456"); expect(mockFetch).toHaveBeenCalledWith( expect.stringContaining( @@ -48,7 +49,7 @@ describe("GitLabSourceControlProvider", () => { const provider = new GitLabSourceControlProvider(fakeConfig); await expect( - provider.getBranchHead({ owner: "acme", name: "web", branch: "missing" }) + provider.getBranchHead({ owner: "acme", name: "web", branch: "missing" }, repositoryScope) ).resolves.toBeNull(); }); @@ -57,7 +58,7 @@ describe("GitLabSourceControlProvider", () => { const provider = new GitLabSourceControlProvider(fakeConfig); const err = await provider - .getBranchHead({ owner: "acme", name: "web", branch: "main" }) + .getBranchHead({ owner: "acme", name: "web", branch: "main" }, repositoryScope) .catch((e: unknown) => e); expect(err).toBeInstanceOf(SourceControlProviderError); @@ -864,9 +865,9 @@ describe("GitLabSourceControlProvider", () => { }); describe("generatePushAuth", () => { - it("returns PAT-type auth context with configured token", async () => { + it("returns the deployment-wide PAT even for a narrowed credential scope", async () => { const provider = new GitLabSourceControlProvider({ accessToken: "glpat-abc123" }); - const auth = await provider.generatePushAuth(); + const auth = await provider.generatePushAuth(repositoryScope); expect(auth).toEqual({ authType: "pat", token: "glpat-abc123" }); }); @@ -879,7 +880,7 @@ describe("GitLabSourceControlProvider", () => { try { const provider = new GitLabSourceControlProvider({ accessToken: "glpat-abc123" }); - const auth = await provider.generateCredentialHelperAuth(); + const auth = await provider.generateCredentialHelperAuth(repositoryScope); expect(auth).toEqual({ username: "oauth2", @@ -1079,7 +1080,10 @@ describe("getPullRequest", () => { mockFetch.mockResolvedValueOnce(makeResponse(baseMrResponse)); const provider = new GitLabSourceControlProvider(fakeConfig); - const snapshot = await provider.getPullRequest({ owner: "acme", name: "web", number: 7 }); + const snapshot = await provider.getPullRequest( + { owner: "acme", name: "web", number: 7 }, + repositoryScope + ); expect(snapshot).toEqual({ number: 7, @@ -1113,7 +1117,10 @@ describe("getPullRequest", () => { ); const provider = new GitLabSourceControlProvider(fakeConfig); - const snapshot = await provider.getPullRequest({ owner: "acme", name: "web", number: 7 }); + const snapshot = await provider.getPullRequest( + { owner: "acme", name: "web", number: 7 }, + repositoryScope + ); expect(snapshot.providerCreatedAt).toBe(Date.parse("2026-07-08T09:00:00.000Z")); expect(snapshot.mergedAt).toBe(Date.parse("2026-07-10T12:00:00.000Z")); @@ -1124,7 +1131,10 @@ describe("getPullRequest", () => { mockFetch.mockResolvedValueOnce(makeResponse({ ...baseMrResponse, state: "merged" })); const provider = new GitLabSourceControlProvider(fakeConfig); - const snapshot = await provider.getPullRequest({ owner: "acme", name: "web", number: 7 }); + const snapshot = await provider.getPullRequest( + { owner: "acme", name: "web", number: 7 }, + repositoryScope + ); expect(snapshot.lifecycleState).toBe("merged"); expect(snapshot.isDraft).toBe(false); @@ -1144,12 +1154,10 @@ describe("getPullRequest", () => { ); const provider = new GitLabSourceControlProvider(fakeConfig); - const snapshot = await provider.getPullRequest({ - owner: "acme", - name: "web", - number: 7, - repositoryExternalId: "9001", - }); + const snapshot = await provider.getPullRequest( + { owner: "acme", name: "web", number: 7, repositoryExternalId: "9001" }, + repositoryScope + ); expect(snapshot.repoName).toBe("web-renamed"); expect(mockFetch.mock.calls[1][0]).toBe("https://gitlab.com/api/v4/projects/9001"); @@ -1163,7 +1171,7 @@ describe("getPullRequest", () => { const provider = new GitLabSourceControlProvider(fakeConfig); const err = await provider - .getPullRequest({ owner: "acme", name: "web", number: 7 }) + .getPullRequest({ owner: "acme", name: "web", number: 7 }, repositoryScope) .catch((e: unknown) => e); expect(err).toBeInstanceOf(SourceControlProviderError); @@ -1234,7 +1242,10 @@ describe("response validation (zod boundary)", () => { ); const provider = new GitLabSourceControlProvider(fakeConfig); - const snapshot = await provider.getPullRequest({ owner: "acme", name: "web", number: 7 }); + const snapshot = await provider.getPullRequest( + { owner: "acme", name: "web", number: 7 }, + repositoryScope + ); expect(snapshot.lifecycleState).toBe("open"); expect(snapshot.isDraft).toBe(false); @@ -1255,7 +1266,7 @@ describe("response validation (zod boundary)", () => { const provider = new GitLabSourceControlProvider(fakeConfig); const err = await provider - .getPullRequest({ owner: "acme", name: "web", number: 7 }) + .getPullRequest({ owner: "acme", name: "web", number: 7 }, repositoryScope) .catch((e: unknown) => e); expect(err).toBeInstanceOf(SourceControlProviderError); @@ -1300,7 +1311,10 @@ describe("response validation (zod boundary)", () => { const provider = new GitLabSourceControlProvider(fakeConfig); const err = await provider - .getPullRequest({ owner: "acme", name: "web", number: 7, repositoryExternalId: "9001" }) + .getPullRequest( + { owner: "acme", name: "web", number: 7, repositoryExternalId: "9001" }, + repositoryScope + ) .catch((e: unknown) => e); expect(err).toBeInstanceOf(SourceControlProviderError); @@ -1314,6 +1328,18 @@ describe("managed-skill repository reads", () => { vi.resetAllMocks(); }); + it("resolves commits with the deployment PAT", async () => { + mockFetch.mockResolvedValueOnce(makeResponse({ id: "abc123" })); + const provider = new GitLabSourceControlProvider(fakeConfig); + + await expect( + provider.resolveCommit({ owner: "acme", name: "skills", ref: "main" }, repositoryScope) + ).resolves.toEqual({ sha: "abc123" }); + expect(mockFetch.mock.calls[0][1].headers).toMatchObject({ + Authorization: `Bearer ${fakeConfig.accessToken}`, + }); + }); + it("classifies symlinks and submodules as unsupported tree entries", async () => { mockFetch.mockResolvedValueOnce( new Response( @@ -1328,7 +1354,10 @@ describe("managed-skill repository reads", () => { ); const provider = new GitLabSourceControlProvider(fakeConfig); - const tree = await provider.listTree({ owner: "acme", name: "skills", commitSha: "abc" }); + const tree = await provider.listTree( + { owner: "acme", name: "skills", commitSha: "abc" }, + repositoryScope + ); expect(tree.entries.map(({ type, executable }) => ({ type, executable }))).toEqual([ { type: "file", executable: false }, @@ -1351,12 +1380,10 @@ describe("managed-skill repository reads", () => { ); const provider = new GitLabSourceControlProvider(fakeConfig); - const tree = await provider.listTree({ - owner: "acme", - name: "skills", - commitSha: "abc", - path: "skills/deploy", - }); + const tree = await provider.listTree( + { owner: "acme", name: "skills", commitSha: "abc", path: "skills/deploy" }, + repositoryScope + ); expect(tree.entries[0]?.path).toBe("skills/deploy/SKILL.md"); const requestUrl = String(mockFetch.mock.calls[0]?.[0]); @@ -1382,12 +1409,10 @@ describe("managed-skill repository reads", () => { .mockResolvedValueOnce(makeResponse([])); const provider = new GitLabSourceControlProvider(fakeConfig); - const tree = await provider.listTree({ - owner: "acme", - name: "skills", - commitSha: "abc", - path: "skills/deploy", - }); + const tree = await provider.listTree( + { owner: "acme", name: "skills", commitSha: "abc", path: "skills/deploy" }, + repositoryScope + ); expect(tree).toMatchObject({ truncated: false }); expect(tree.entries).toHaveLength(100); @@ -1400,12 +1425,10 @@ describe("managed-skill repository reads", () => { const provider = new GitLabSourceControlProvider(fakeConfig); await expect( - provider.listTree({ - owner: "acme", - name: "skills", - commitSha: "abc", - path: "missing", - }) + provider.listTree( + { owner: "acme", name: "skills", commitSha: "abc", path: "missing" }, + repositoryScope + ) ).resolves.toEqual({ entries: [], truncated: false }); }); @@ -1424,7 +1447,7 @@ describe("managed-skill repository reads", () => { const provider = new GitLabSourceControlProvider(fakeConfig); const error = await provider - .readBlob({ owner: "acme", name: "skills", blobId: "big", maxBytes: 4 }) + .readBlob({ owner: "acme", name: "skills", blobId: "big", maxBytes: 4 }, repositoryScope) .catch((thrown: unknown) => thrown); expect(error).toBeInstanceOf(SourceControlProviderError); diff --git a/packages/control-plane/src/source-control/providers/gitlab-provider.ts b/packages/control-plane/src/source-control/providers/gitlab-provider.ts index 5634032bcf..3e0191f04d 100644 --- a/packages/control-plane/src/source-control/providers/gitlab-provider.ts +++ b/packages/control-plane/src/source-control/providers/gitlab-provider.ts @@ -23,6 +23,7 @@ import type { GitPushSpec, GitPushAuthContext, CredentialHelperAuth, + CredentialScope, ResolvedCommit, RepositoryTree, RepositoryTreeEntry, @@ -203,6 +204,7 @@ function parseProviderTimestamp(value: string | null | undefined): number | unde * Uses Personal Access Tokens for all API calls. The PAT must have * `read_api` scope for read operations and `api` scope for write operations * (creating merge requests, push). + * Credential scopes are ignored: the deployment-wide PAT cannot be narrowed per call. */ export class GitLabSourceControlProvider implements SourceControlProvider { readonly name = "gitlab"; @@ -353,7 +355,10 @@ export class GitLabSourceControlProvider implements SourceControlProvider { * On a 404 with a known stable project id, re-resolves the project's * current path by id and retries once (rename/transfer tolerance). */ - async getPullRequest(config: GetPullRequestConfig): Promise { + async getPullRequest( + config: GetPullRequestConfig, + _scope: CredentialScope + ): Promise { let owner = config.owner; let name = config.name; let response = await this.fetchMergeRequest(owner, name, config.number); @@ -578,7 +583,10 @@ export class GitLabSourceControlProvider implements SourceControlProvider { } } - async getBranchHead(config: GetRepositoryConfig & { branch: string }): Promise { + async getBranchHead( + config: GetRepositoryConfig & { branch: string }, + _scope: CredentialScope + ): Promise { const projectPath = encodeProjectPath(config.owner, config.name); try { const response = await fetchWithTimeout( @@ -612,7 +620,8 @@ export class GitLabSourceControlProvider implements SourceControlProvider { } async resolveCommit( - config: GetRepositoryConfig & { ref: string } + config: GetRepositoryConfig & { ref: string }, + _scope: CredentialScope ): Promise { const projectPath = encodeProjectPath(config.owner, config.name); const response = await this.patFetch( @@ -630,7 +639,8 @@ export class GitLabSourceControlProvider implements SourceControlProvider { } async listTree( - config: GetRepositoryConfig & { commitSha: string; path?: string | null } + config: GetRepositoryConfig & { commitSha: string; path?: string | null }, + _scope: CredentialScope ): Promise { const projectPath = encodeProjectPath(config.owner, config.name); const entries: RepositoryTreeEntry[] = []; @@ -674,7 +684,8 @@ export class GitLabSourceControlProvider implements SourceControlProvider { } async readBlob( - config: GetRepositoryConfig & { blobId: string; maxBytes: number } + config: GetRepositoryConfig & { blobId: string; maxBytes: number }, + _scope: CredentialScope ): Promise { const projectPath = encodeProjectPath(config.owner, config.name); const response = await this.patFetch( @@ -702,14 +713,14 @@ export class GitLabSourceControlProvider implements SourceControlProvider { /** * Generate authentication for git push operations using the provider PAT. */ - async generatePushAuth(): Promise { + async generatePushAuth(_scope: CredentialScope): Promise { return { authType: "pat", token: this.accessToken, }; } - async generateCredentialHelperAuth(): Promise { + async generateCredentialHelperAuth(_scope: CredentialScope): Promise { return { username: "oauth2", password: this.accessToken, diff --git a/packages/control-plane/src/source-control/repository-scope.test.ts b/packages/control-plane/src/source-control/repository-scope.test.ts new file mode 100644 index 0000000000..074dc326d3 --- /dev/null +++ b/packages/control-plane/src/source-control/repository-scope.test.ts @@ -0,0 +1,158 @@ +import type { InstallationRepository } from "@open-inspect/shared/types/repository-catalog"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { SqlDatabase } from "../db/sql-database"; +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import { MAX_CREDENTIAL_SCOPE_REPOSITORY_IDS } from "./credential-scope"; +import { resolveRepositoryCredentialScope } from "./repository-scope"; + +const db = {} as SqlDatabase; +const loadCatalog = vi.fn<() => Promise>(); + +function repository(repoId: number | null, repoOwner = "acme", repoName = "web") { + return { repoOwner, repoName, repoId }; +} + +function catalogRepository(id: number, owner: string, name: string): InstallationRepository { + return { + id, + owner, + name, + fullName: `${owner}/${name}`, + description: null, + private: true, + defaultBranch: "main", + archived: false, + }; +} + +describe("resolveRepositoryCredentialScope", () => { + beforeEach(() => { + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([]); + loadCatalog.mockReset().mockResolvedValue([]); + }); + + afterEach(() => vi.restoreAllMocks()); + + it("sorts and deduplicates known ids without loading the catalog or grants", async () => { + const repositories = [repository(30), repository(12, "acme", "api"), repository(30)]; + + expect(await resolveRepositoryCredentialScope(db, repositories, null, loadCatalog)).toEqual({ + kind: "repositories", + repositoryIds: [12, 30], + }); + expect(loadCatalog).not.toHaveBeenCalled(); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + }); + + it("resolves NULL ids from the catalog by nested owner and name case-insensitively", async () => { + loadCatalog.mockResolvedValue([ + catalogRepository(12, "Group/Subgroup", "Web"), + catalogRepository(30, "group/subgroup", "API"), + catalogRepository(99, "other", "unrelated"), + ]); + + expect( + await resolveRepositoryCredentialScope( + db, + [ + repository(null, "GROUP/SUBGROUP", "web"), + repository(null, "group/subgroup", "api"), + repository(50, "acme", "known"), + ], + null, + loadCatalog + ) + ).toEqual({ kind: "repositories", repositoryIds: [12, 30, 50] }); + expect(loadCatalog).toHaveBeenCalledTimes(1); + }); + + it("refuses the entire scope if any NULL id is unresolved, ignoring catalog fullName", async () => { + loadCatalog.mockResolvedValue([ + catalogRepository(12, "acme", "web"), + { ...catalogRepository(99, "other", "missing"), fullName: "acme/missing" }, + ]); + + await expect( + resolveRepositoryCredentialScope( + db, + [repository(null), repository(null, "acme", "missing")], + "team-a", + loadCatalog + ) + ).rejects.toMatchObject({ errorType: "permanent" }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + }); + + it.each([ + { source: "stored", repositories: [repository(0)], catalog: [] }, + { + source: "catalog", + repositories: [repository(null)], + catalog: [catalogRepository(Number.NaN, "acme", "web")], + }, + ])("refuses an invalid $source id before reading grants", async ({ repositories, catalog }) => { + loadCatalog.mockResolvedValue(catalog); + + await expect( + resolveRepositoryCredentialScope(db, repositories, "team-a", loadCatalog) + ).rejects.toMatchObject({ errorType: "permanent" }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + }); + + it("refuses an empty repository set without reading grants", async () => { + await expect( + resolveRepositoryCredentialScope(db, [], "team-a", loadCatalog) + ).rejects.toMatchObject({ errorType: "permanent" }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + }); + + it("filters candidates to the owning team's repository grants", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 12 }, + { grant_kind: "repository", repo_external_id: 99 }, + ]); + + expect( + await resolveRepositoryCredentialScope( + db, + [repository(30), repository(12, "acme", "api")], + "team-a", + loadCatalog + ) + ).toEqual({ kind: "repositories", repositoryIds: [12] }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).toHaveBeenCalledWith("team-a"); + }); + + it("keeps all candidates, and only candidates, under an installation grant", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "installation", repo_external_id: null }, + ]); + loadCatalog.mockResolvedValue([ + catalogRepository(12, "acme", "web"), + catalogRepository(99, "other", "not-in-session"), + ]); + + expect( + await resolveRepositoryCredentialScope( + db, + [repository(null), repository(30, "acme", "api")], + "team-a", + loadCatalog + ) + ).toEqual({ kind: "repositories", repositoryIds: [12, 30] }); + }); + + it("applies the scope limit to granted candidates, not the pre-filter repository count", async () => { + const repositories = Array.from( + { length: MAX_CREDENTIAL_SCOPE_REPOSITORY_IDS + 1 }, + (_, index) => repository(index + 1, "acme", `repo-${index}`) + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 12 }, + ]); + + expect(await resolveRepositoryCredentialScope(db, repositories, "team-a", loadCatalog)).toEqual( + { kind: "repositories", repositoryIds: [12] } + ); + }); +}); diff --git a/packages/control-plane/src/source-control/repository-scope.ts b/packages/control-plane/src/source-control/repository-scope.ts new file mode 100644 index 0000000000..2ee162766e --- /dev/null +++ b/packages/control-plane/src/source-control/repository-scope.ts @@ -0,0 +1,43 @@ +import type { InstallationRepository } from "@open-inspect/shared/types/repository-catalog"; +import type { SqlDatabase } from "../db/sql-database"; +import { coveredRepositoryIds, TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import { repositoryCredentialScope, type CredentialScope } from "./credential-scope"; +import { SourceControlProviderError } from "./errors"; + +/** Resolve only the supplied repositories, intersecting current team grants when owned. */ +export async function resolveRepositoryCredentialScope( + db: SqlDatabase, + repositories: readonly { repoOwner: string; repoName: string; repoId: number | null }[], + ownerTeamId: string | null, + loadInstallationRepositories: () => Promise +): Promise { + const catalog = repositories.some((repository) => repository.repoId === null) + ? await loadInstallationRepositories() + : []; + const candidateIds = [ + ...new Set( + repositories.map((repository) => { + const repoId = + repository.repoId ?? + catalog.find( + (entry) => + entry.owner.toLowerCase() === repository.repoOwner.toLowerCase() && + entry.name.toLowerCase() === repository.repoName.toLowerCase() + )?.id; + if (repoId === undefined || !Number.isSafeInteger(repoId) || repoId <= 0) { + throw new SourceControlProviderError( + `Cannot resolve credential scope: repository id unavailable or invalid for ${repository.repoOwner}/${repository.repoName}`, + "permanent" + ); + } + return repoId; + }) + ), + ]; + + if (ownerTeamId !== null && candidateIds.length > 0) { + const grants = await new TeamRepositoryGrantStore(db).listForTeam(ownerTeamId); + return repositoryCredentialScope(coveredRepositoryIds(grants, candidateIds)); + } + return repositoryCredentialScope(candidateIds); +} diff --git a/packages/control-plane/src/source-control/session-scope.test.ts b/packages/control-plane/src/source-control/session-scope.test.ts new file mode 100644 index 0000000000..bd6b8a4771 --- /dev/null +++ b/packages/control-plane/src/source-control/session-scope.test.ts @@ -0,0 +1,106 @@ +import type { InstallationRepository } from "@open-inspect/shared/types/repository-catalog"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SessionIndexStore, type SessionEntry } from "../db/session-index"; +import { SessionRepositoryStore } from "../db/session-repositories"; +import type { SqlDatabase } from "../db/sql-database"; +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import { SourceControlProviderError } from "./errors"; +import { resolveSessionCredentialScope } from "./session-scope"; + +const db = {} as SqlDatabase; +const SESSION_ID = "public-session"; +const loadCatalog = vi.fn<() => Promise>(); + +function indexSession(overrides: Partial = {}): SessionEntry { + return { + id: SESSION_ID, + ownerTeamId: null, + repoOwner: null, + repoName: null, + ...overrides, + } as SessionEntry; +} + +describe("resolveSessionCredentialScope", () => { + beforeEach(() => { + vi.spyOn(SessionIndexStore.prototype, "get").mockResolvedValue(indexSession()); + vi.spyOn(SessionRepositoryStore.prototype, "listRepositoryIds").mockResolvedValue([ + { repoOwner: "acme", repoName: "web", repoId: 30 }, + { repoOwner: "acme", repoName: "api", repoId: 12 }, + ]); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([]); + loadCatalog.mockReset().mockResolvedValue([]); + }); + + afterEach(() => vi.restoreAllMocks()); + + it("fails closed without reading membership when the session is missing", async () => { + vi.mocked(SessionIndexStore.prototype.get).mockResolvedValue(null); + + const error = await resolveSessionCredentialScope(db, SESSION_ID, loadCatalog).catch( + (caught: unknown) => caught + ); + + expect(error).toBeInstanceOf(SourceControlProviderError); + expect(error).toMatchObject({ + message: "Cannot resolve credential scope: session not found", + errorType: "permanent", + }); + expect(SessionRepositoryStore.prototype.listRepositoryIds).not.toHaveBeenCalled(); + }); + + it("scopes credentials to the persisted session members", async () => { + expect(await resolveSessionCredentialScope(db, SESSION_ID, loadCatalog)).toEqual({ + kind: "repositories", + repositoryIds: [12, 30], + }); + expect(SessionRepositoryStore.prototype.listRepositoryIds).toHaveBeenCalledWith(SESSION_ID); + }); + + it("falls back to the session's scalar repository via the catalog when there are no members", async () => { + vi.mocked(SessionIndexStore.prototype.get).mockResolvedValue( + indexSession({ repoOwner: "acme", repoName: "web" }) + ); + vi.mocked(SessionRepositoryStore.prototype.listRepositoryIds).mockResolvedValue([]); + loadCatalog.mockResolvedValue([ + { + id: 12, + owner: "ACME", + name: "Web", + fullName: "ACME/Web", + description: null, + private: true, + defaultBranch: "main", + archived: false, + }, + ]); + + expect(await resolveSessionCredentialScope(db, SESSION_ID, loadCatalog)).toEqual({ + kind: "repositories", + repositoryIds: [12], + }); + }); + + it("refuses a session with neither members nor a scalar repository", async () => { + vi.mocked(SessionRepositoryStore.prototype.listRepositoryIds).mockResolvedValue([]); + + await expect(resolveSessionCredentialScope(db, SESSION_ID, loadCatalog)).rejects.toMatchObject({ + errorType: "permanent", + }); + }); + + it("intersects members with the owning team's current grants", async () => { + vi.mocked(SessionIndexStore.prototype.get).mockResolvedValue( + indexSession({ ownerTeamId: "team-a" }) + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 12 }, + ]); + + expect(await resolveSessionCredentialScope(db, SESSION_ID, loadCatalog)).toEqual({ + kind: "repositories", + repositoryIds: [12], + }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).toHaveBeenCalledWith("team-a"); + }); +}); diff --git a/packages/control-plane/src/source-control/session-scope.ts b/packages/control-plane/src/source-control/session-scope.ts new file mode 100644 index 0000000000..7bbca600b3 --- /dev/null +++ b/packages/control-plane/src/source-control/session-scope.ts @@ -0,0 +1,34 @@ +import type { InstallationRepository } from "@open-inspect/shared/types/repository-catalog"; +import { SessionIndexStore } from "../db/session-index"; +import { SessionRepositoryStore } from "../db/session-repositories"; +import type { SqlDatabase } from "../db/sql-database"; +import type { CredentialScope } from "./credential-scope"; +import { SourceControlProviderError } from "./errors"; +import { resolveRepositoryCredentialScope } from "./repository-scope"; + +/** Resolve fresh ownership and persisted session members, never environment provenance. */ +export async function resolveSessionCredentialScope( + db: SqlDatabase, + sessionId: string, + loadInstallationRepositories: () => Promise +): Promise { + const session = await new SessionIndexStore(db).get(sessionId); + if (!session) { + throw new SourceControlProviderError( + "Cannot resolve credential scope: session not found", + "permanent" + ); + } + const members = await new SessionRepositoryStore(db).listRepositoryIds(sessionId); + const repositories = members.length + ? members + : session.repoOwner && session.repoName + ? [{ repoOwner: session.repoOwner, repoName: session.repoName, repoId: null }] + : []; + return await resolveRepositoryCredentialScope( + db, + repositories, + session.ownerTeamId, + loadInstallationRepositories + ); +} diff --git a/packages/control-plane/src/source-control/types.ts b/packages/control-plane/src/source-control/types.ts index 69b57c1ddf..70cf066496 100644 --- a/packages/control-plane/src/source-control/types.ts +++ b/packages/control-plane/src/source-control/types.ts @@ -6,6 +6,9 @@ import type { InstallationRepository } from "@open-inspect/shared/types/repository-catalog"; import type { PullRequestLifecycleState } from "@open-inspect/shared/types/artifacts"; +import type { CredentialScope } from "./credential-scope"; + +export type { CredentialScope } from "./credential-scope"; /** * Repository information. @@ -305,6 +308,10 @@ export interface PullRequestSnapshot { * Defines the contract for source control platform operations. * Implementations wrap provider-specific APIs (GitHub, GitLab, Bitbucket). * + * App-level credential scope: + * - GitHub honors CredentialScope when minting installation tokens. + * - GitLab uses a deployment-wide PAT and does not narrow it by scope. + * * Error handling: * - Methods should throw SourceControlProviderError with appropriate errorType * - "transient" errors (network issues) can be retried @@ -407,7 +414,10 @@ export interface SourceControlProvider { * Resolve one branch tip with app-level credentials. A confirmed 404 is * absence; authentication, throttling, and transport failures throw. */ - getBranchHead(config: GetRepositoryConfig & { branch: string }): Promise; + getBranchHead( + config: GetRepositoryConfig & { branch: string }, + scope: CredentialScope + ): Promise; /** * Resolve a branch, tag, or commit-ish to the commit it names. @@ -419,7 +429,10 @@ export interface SourceControlProvider { * @returns The resolved commit, or null when the ref does not exist * @throws SourceControlProviderError */ - resolveCommit(config: GetRepositoryConfig & { ref: string }): Promise; + resolveCommit( + config: GetRepositoryConfig & { ref: string }, + scope: CredentialScope + ): Promise; /** * List every entry reachable from a commit, recursively. @@ -432,7 +445,8 @@ export interface SourceControlProvider { * @throws SourceControlProviderError */ listTree( - config: GetRepositoryConfig & { commitSha: string; path?: string | null } + config: GetRepositoryConfig & { commitSha: string; path?: string | null }, + scope: CredentialScope ): Promise; /** @@ -451,21 +465,28 @@ export interface SourceControlProvider { * largest body the caller is willing to accept * @throws SourceControlProviderError, including when the blob is too large */ - readBlob(config: GetRepositoryConfig & { blobId: string; maxBytes: number }): Promise; + readBlob( + config: GetRepositoryConfig & { blobId: string; maxBytes: number }, + scope: CredentialScope + ): Promise; /** * Read the current state of a pull request. * - * App-authenticated: credentials come from provider-level configuration - * (matching listRepositories), never a caller token — the webhook and - * read-through freshness paths run with no user in the loop. + * App-authenticated: credentials come from provider-level configuration, + * never a caller token — the webhook and read-through freshness paths run + * with no user in the loop. GitHub honors the caller's scope; GitLab's + * deployment-wide PAT is not narrowed by it. * * @param config - PR identifier; include repositoryExternalId when known * so a 404 triggers a resolve-by-id + single retry (rename tolerance) * @returns Current PR snapshot * @throws SourceControlProviderError */ - getPullRequest(config: GetPullRequestConfig): Promise; + getPullRequest( + config: GetPullRequestConfig, + scope: CredentialScope + ): Promise; /** * Generate authentication for git push operations. @@ -477,7 +498,7 @@ export interface SourceControlProvider { * @returns Git push authentication context with app token * @throws SourceControlProviderError */ - generatePushAuth(): Promise; + generatePushAuth(scope: CredentialScope): Promise; /** * Generate credentials for the sandbox's git credential helper. @@ -485,12 +506,13 @@ export interface SourceControlProvider { * Called per request from inside the sandbox via * `POST /sessions/:id/scm-credentials`. The returned `username` is the * provider-specific basic-auth username (e.g. `x-access-token` for GitHub), - * and `password` is a freshly minted token. `expiresAtEpochMs` lets the - * client side cache the credentials until shortly before they expire. + * and `password` is a scoped installation token for GitHub or the + * deployment-wide PAT for GitLab. `expiresAtEpochMs` lets the client side + * cache the credentials until shortly before they expire. * * @throws SourceControlProviderError on configuration or upstream errors */ - generateCredentialHelperAuth(): Promise; + generateCredentialHelperAuth(scope: CredentialScope): Promise; /** * Build provider-specific URL for manual pull request creation. diff --git a/packages/control-plane/test/integration/__snapshots__/hono-route-catalog-conformance.test.ts.snap b/packages/control-plane/test/integration/__snapshots__/hono-route-catalog-conformance.test.ts.snap index 1906d30879..a4804d9e74 100644 --- a/packages/control-plane/test/integration/__snapshots__/hono-route-catalog-conformance.test.ts.snap +++ b/packages/control-plane/test/integration/__snapshots__/hono-route-catalog-conformance.test.ts.snap @@ -22,82 +22,85 @@ exports[`Hono route catalog conformance > dispatches every frozen method/path/po "{"identity":"DELETE /teams/:id/members/:userId","pathname":"/teams/fixture-17-id%2Fraw/members/fixture-17-userId%2Fraw","groups":{"id":"fixture-17-id%2Fraw","userId":"fixture-17-userId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"removeMember","targetUserIdParam":"userId"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"POST /teams/:id/join","pathname":"/teams/fixture-18-id%2Fraw/join","groups":{"id":"fixture-18-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canJoin"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"GET /teams/:id/sessions","pathname":"/teams/fixture-19-id%2Fraw/sessions","groups":{"id":"fixture-19-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"member"},{"kind":"permission","permission":"sessions.read"}],"service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /teams/:id/repository-grants","pathname":"/teams/fixture-20-id%2Fraw/repository-grants","groups":{"id":"fixture-20-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"member"}],"service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"PUT /teams/:id/repository-grants","pathname":"/teams/fixture-21-id%2Fraw/repository-grants","groups":{"id":"fixture-21-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageRepositories"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"DELETE /teams/:id/repository-grants/:grantId","pathname":"/teams/fixture-22-id%2Fraw/repository-grants/fixture-22-grantId%2Fraw","groups":{"id":"fixture-22-id%2Fraw","grantId":"fixture-22-grantId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageRepositories"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"GET /settings/teams","pathname":"/settings/teams","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.manage"}],"auditAllowed":true,"service":{"kind":"deny"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"PATCH /settings/teams","pathname":"/settings/teams","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.manage"}],"auditAllowed":true,"service":{"kind":"deny"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /sessions","pathname":"/sessions","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.create"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":true}", - "{"identity":"PUT /sessions/:id/visibility","pathname":"/sessions/fixture-23-id%2Fraw/visibility","groups":{"id":"fixture-23-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"changeVisibility","enforceAlways":true}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /sessions/:id/collaborators/:userId","pathname":"/sessions/fixture-24-id%2Fraw/collaborators/fixture-24-userId%2Fraw","groups":{"id":"fixture-24-id%2Fraw","userId":"fixture-24-userId%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"manageCollaborators","enforceAlways":true}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /sessions/:id/collaborators/:userId","pathname":"/sessions/fixture-25-id%2Fraw/collaborators/fixture-25-userId%2Fraw","groups":{"id":"fixture-25-id%2Fraw","userId":"fixture-25-userId%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read","enforceAlways":true}],"service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/collaborator-candidates","pathname":"/sessions/fixture-26-id%2Fraw/collaborator-candidates","groups":{"id":"fixture-26-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"manageCollaborators","enforceAlways":true}],"service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"PUT /sessions/:id/visibility","pathname":"/sessions/fixture-26-id%2Fraw/visibility","groups":{"id":"fixture-26-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"changeVisibility","enforceAlways":true}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /sessions/:id/collaborators/:userId","pathname":"/sessions/fixture-27-id%2Fraw/collaborators/fixture-27-userId%2Fraw","groups":{"id":"fixture-27-id%2Fraw","userId":"fixture-27-userId%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"manageCollaborators","enforceAlways":true}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /sessions/:id/collaborators/:userId","pathname":"/sessions/fixture-28-id%2Fraw/collaborators/fixture-28-userId%2Fraw","groups":{"id":"fixture-28-id%2Fraw","userId":"fixture-28-userId%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read","enforceAlways":true}],"service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/collaborator-candidates","pathname":"/sessions/fixture-29-id%2Fraw/collaborator-candidates","groups":{"id":"fixture-29-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"manageCollaborators","enforceAlways":true}],"service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"GET /sessions","pathname":"/sessions","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /sessions/inbox","pathname":"/sessions/inbox","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"deny"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PATCH /sessions/:id/read-state","pathname":"/sessions/fixture-29-id%2Fraw/read-state","groups":{"id":"fixture-29-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /sessions/:id","pathname":"/sessions/fixture-30-id%2Fraw","groups":{"id":"fixture-30-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"delete"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PATCH /sessions/:id/read-state","pathname":"/sessions/fixture-32-id%2Fraw/read-state","groups":{"id":"fixture-32-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /sessions/:id","pathname":"/sessions/fixture-33-id%2Fraw","groups":{"id":"fixture-33-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"delete"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /sessions/export","pathname":"/sessions/export","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.export"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/export","pathname":"/sessions/fixture-32-id%2Fraw/export","groups":{"id":"fixture-32-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"},{"kind":"permission","permission":"sessions.export"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/sandbox-access","pathname":"/sessions/fixture-33-id%2Fraw/sandbox-access","groups":{"id":"fixture-33-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"sandbox"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id","pathname":"/sessions/fixture-34-id%2Fraw","groups":{"id":"fixture-34-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/stop","pathname":"/sessions/fixture-35-id%2Fraw/stop","groups":{"id":"fixture-35-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor","actorlessGrants":[{"service":"linear-bot"}]},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/sandbox-error","pathname":"/sessions/fixture-36-id%2Fraw/sandbox-error","groups":{"id":"fixture-36-id%2Fraw"},"authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/events","pathname":"/sessions/fixture-37-id%2Fraw/events","groups":{"id":"fixture-37-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"},{"service":"linear-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/artifacts","pathname":"/sessions/fixture-38-id%2Fraw/artifacts","groups":{"id":"fixture-38-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"},{"service":"linear-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/participants","pathname":"/sessions/fixture-39-id%2Fraw/participants","groups":{"id":"fixture-39-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/participant-profiles","pathname":"/sessions/fixture-40-id%2Fraw/participant-profiles","groups":{"id":"fixture-40-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/messages","pathname":"/sessions/fixture-41-id%2Fraw/messages","groups":{"id":"fixture-41-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/pr","pathname":"/sessions/fixture-42-id%2Fraw/pr","groups":{"id":"fixture-42-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/openai-token-refresh","pathname":"/sessions/fixture-43-id%2Fraw/openai-token-refresh","groups":{"id":"fixture-43-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/xai-token-refresh","pathname":"/sessions/fixture-44-id%2Fraw/xai-token-refresh","groups":{"id":"fixture-44-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/scm-credentials","pathname":"/sessions/fixture-45-id%2Fraw/scm-credentials","groups":{"id":"fixture-45-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":["github","gitlab"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/tunnel-urls","pathname":"/sessions/fixture-46-id%2Fraw/tunnel-urls","groups":{"id":"fixture-46-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"sandbox"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PATCH /sessions/:id/title","pathname":"/sessions/fixture-47-id%2Fraw/title","groups":{"id":"fixture-47-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/archive","pathname":"/sessions/fixture-48-id%2Fraw/archive","groups":{"id":"fixture-48-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/unarchive","pathname":"/sessions/fixture-49-id%2Fraw/unarchive","groups":{"id":"fixture-49-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PATCH /sessions/:id/budget","pathname":"/sessions/fixture-50-id%2Fraw/budget","groups":{"id":"fixture-50-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/export","pathname":"/sessions/fixture-35-id%2Fraw/export","groups":{"id":"fixture-35-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"},{"kind":"permission","permission":"sessions.export"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/sandbox-access","pathname":"/sessions/fixture-36-id%2Fraw/sandbox-access","groups":{"id":"fixture-36-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"sandbox"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id","pathname":"/sessions/fixture-37-id%2Fraw","groups":{"id":"fixture-37-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/stop","pathname":"/sessions/fixture-38-id%2Fraw/stop","groups":{"id":"fixture-38-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor","actorlessGrants":[{"service":"linear-bot"}]},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/sandbox-error","pathname":"/sessions/fixture-39-id%2Fraw/sandbox-error","groups":{"id":"fixture-39-id%2Fraw"},"authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/events","pathname":"/sessions/fixture-40-id%2Fraw/events","groups":{"id":"fixture-40-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"},{"service":"linear-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/artifacts","pathname":"/sessions/fixture-41-id%2Fraw/artifacts","groups":{"id":"fixture-41-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"},{"service":"linear-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/participants","pathname":"/sessions/fixture-42-id%2Fraw/participants","groups":{"id":"fixture-42-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/participant-profiles","pathname":"/sessions/fixture-43-id%2Fraw/participant-profiles","groups":{"id":"fixture-43-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/messages","pathname":"/sessions/fixture-44-id%2Fraw/messages","groups":{"id":"fixture-44-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/pr","pathname":"/sessions/fixture-45-id%2Fraw/pr","groups":{"id":"fixture-45-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/openai-token-refresh","pathname":"/sessions/fixture-46-id%2Fraw/openai-token-refresh","groups":{"id":"fixture-46-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/xai-token-refresh","pathname":"/sessions/fixture-47-id%2Fraw/xai-token-refresh","groups":{"id":"fixture-47-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/scm-credentials","pathname":"/sessions/fixture-48-id%2Fraw/scm-credentials","groups":{"id":"fixture-48-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":["github","gitlab"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/tunnel-urls","pathname":"/sessions/fixture-49-id%2Fraw/tunnel-urls","groups":{"id":"fixture-49-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"sandbox"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PATCH /sessions/:id/title","pathname":"/sessions/fixture-50-id%2Fraw/title","groups":{"id":"fixture-50-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/archive","pathname":"/sessions/fixture-51-id%2Fraw/archive","groups":{"id":"fixture-51-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/unarchive","pathname":"/sessions/fixture-52-id%2Fraw/unarchive","groups":{"id":"fixture-52-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PATCH /sessions/:id/budget","pathname":"/sessions/fixture-53-id%2Fraw/budget","groups":{"id":"fixture-53-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /sessions/batch-archive","pathname":"/sessions/batch-archive","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.bulk_archive"}],"auditAllowed":true,"service":{"kind":"deny"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/ws-token","pathname":"/sessions/fixture-52-id%2Fraw/ws-token","groups":{"id":"fixture-52-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/prompt","pathname":"/sessions/fixture-53-id%2Fraw/prompt","groups":{"id":"fixture-53-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/pull-requests/refresh","pathname":"/sessions/fixture-54-id%2Fraw/pull-requests/refresh","groups":{"id":"fixture-54-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/media","pathname":"/sessions/fixture-55-id%2Fraw/media","groups":{"id":"fixture-55-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/media/:artifactId","pathname":"/sessions/fixture-56-id%2Fraw/media/fixture-56-artifactId%2Fraw","groups":{"id":"fixture-56-id%2Fraw","artifactId":"fixture-56-artifactId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/attachments","pathname":"/sessions/fixture-57-id%2Fraw/attachments","groups":{"id":"fixture-57-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/attachments/:attachmentId","pathname":"/sessions/fixture-58-id%2Fraw/attachments/fixture-58-attachmentId%2Fraw","groups":{"id":"fixture-58-id%2Fraw","attachmentId":"fixture-58-attachmentId%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/diff","pathname":"/sessions/fixture-59-id%2Fraw/diff","groups":{"id":"fixture-59-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /sessions/:id/diff","pathname":"/sessions/fixture-60-id%2Fraw/diff","groups":{"id":"fixture-60-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/diff/failure","pathname":"/sessions/fixture-61-id%2Fraw/diff/failure","groups":{"id":"fixture-61-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/diff/:revisionId/files/:fileId","pathname":"/sessions/fixture-62-id%2Fraw/diff/fixture-62-revisionId%2Fraw/files/fixture-62-fileId%2Fraw","groups":{"id":"fixture-62-id%2Fraw","revisionId":"fixture-62-revisionId%2Fraw","fileId":"fixture-62-fileId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/diff/retry","pathname":"/sessions/fixture-63-id%2Fraw/diff/retry","groups":{"id":"fixture-63-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/skills","pathname":"/sessions/fixture-64-id%2Fraw/skills","groups":{"id":"fixture-64-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/sandbox-skills","pathname":"/sessions/fixture-65-id%2Fraw/sandbox-skills","groups":{"id":"fixture-65-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/children","pathname":"/sessions/fixture-66-id%2Fraw/children","groups":{"id":"fixture-66-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"},{"kind":"permission","permission":"sessions.create"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/children","pathname":"/sessions/fixture-67-id%2Fraw/children","groups":{"id":"fixture-67-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/children/:childId","pathname":"/sessions/fixture-68-id%2Fraw/children/fixture-68-childId%2Fraw","groups":{"id":"fixture-68-id%2Fraw","childId":"fixture-68-childId%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"},{"kind":"session","sessionIdParam":"childId","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/children/:childId/cancel","pathname":"/sessions/fixture-69-id%2Fraw/children/fixture-69-childId%2Fraw/cancel","groups":{"id":"fixture-69-id%2Fraw","childId":"fixture-69-childId%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"},{"kind":"session","sessionIdParam":"childId","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/children/:childId/prompt","pathname":"/sessions/fixture-70-id%2Fraw/children/fixture-70-childId%2Fraw/prompt","groups":{"id":"fixture-70-id%2Fraw","childId":"fixture-70-childId%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/slack-notify","pathname":"/sessions/fixture-71-id%2Fraw/slack-notify","groups":{"id":"fixture-71-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/ws-token","pathname":"/sessions/fixture-55-id%2Fraw/ws-token","groups":{"id":"fixture-55-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/prompt","pathname":"/sessions/fixture-56-id%2Fraw/prompt","groups":{"id":"fixture-56-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/pull-requests/refresh","pathname":"/sessions/fixture-57-id%2Fraw/pull-requests/refresh","groups":{"id":"fixture-57-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/media","pathname":"/sessions/fixture-58-id%2Fraw/media","groups":{"id":"fixture-58-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/media/:artifactId","pathname":"/sessions/fixture-59-id%2Fraw/media/fixture-59-artifactId%2Fraw","groups":{"id":"fixture-59-id%2Fraw","artifactId":"fixture-59-artifactId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/attachments","pathname":"/sessions/fixture-60-id%2Fraw/attachments","groups":{"id":"fixture-60-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/attachments/:attachmentId","pathname":"/sessions/fixture-61-id%2Fraw/attachments/fixture-61-attachmentId%2Fraw","groups":{"id":"fixture-61-id%2Fraw","attachmentId":"fixture-61-attachmentId%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/diff","pathname":"/sessions/fixture-62-id%2Fraw/diff","groups":{"id":"fixture-62-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /sessions/:id/diff","pathname":"/sessions/fixture-63-id%2Fraw/diff","groups":{"id":"fixture-63-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/diff/failure","pathname":"/sessions/fixture-64-id%2Fraw/diff/failure","groups":{"id":"fixture-64-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/diff/:revisionId/files/:fileId","pathname":"/sessions/fixture-65-id%2Fraw/diff/fixture-65-revisionId%2Fraw/files/fixture-65-fileId%2Fraw","groups":{"id":"fixture-65-id%2Fraw","revisionId":"fixture-65-revisionId%2Fraw","fileId":"fixture-65-fileId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/diff/retry","pathname":"/sessions/fixture-66-id%2Fraw/diff/retry","groups":{"id":"fixture-66-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/skills","pathname":"/sessions/fixture-67-id%2Fraw/skills","groups":{"id":"fixture-67-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/sandbox-skills","pathname":"/sessions/fixture-68-id%2Fraw/sandbox-skills","groups":{"id":"fixture-68-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/children","pathname":"/sessions/fixture-69-id%2Fraw/children","groups":{"id":"fixture-69-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"},{"kind":"permission","permission":"sessions.create"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/children","pathname":"/sessions/fixture-70-id%2Fraw/children","groups":{"id":"fixture-70-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/children/:childId","pathname":"/sessions/fixture-71-id%2Fraw/children/fixture-71-childId%2Fraw","groups":{"id":"fixture-71-id%2Fraw","childId":"fixture-71-childId%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"},{"kind":"session","sessionIdParam":"childId","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/children/:childId/cancel","pathname":"/sessions/fixture-72-id%2Fraw/children/fixture-72-childId%2Fraw/cancel","groups":{"id":"fixture-72-id%2Fraw","childId":"fixture-72-childId%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"},{"kind":"session","sessionIdParam":"childId","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/children/:childId/prompt","pathname":"/sessions/fixture-73-id%2Fraw/children/fixture-73-childId%2Fraw/prompt","groups":{"id":"fixture-73-id%2Fraw","childId":"fixture-73-childId%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/slack-notify","pathname":"/sessions/fixture-74-id%2Fraw/slack-notify","groups":{"id":"fixture-74-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /repos","pathname":"/repos","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"},{"service":"linear-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /repos/:owner/:name/metadata","pathname":"/repos/fixture-73-owner%2Fraw/fixture-73-name%2Fraw/metadata","groups":{"owner":"fixture-73-owner%2Fraw","name":"fixture-73-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /repos/:owner/:name/metadata","pathname":"/repos/fixture-74-owner%2Fraw/fixture-74-name%2Fraw/metadata","groups":{"owner":"fixture-74-owner%2Fraw","name":"fixture-74-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"github-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /repos/:owner/:name/branches","pathname":"/repos/fixture-75-owner%2Fraw/fixture-75-name%2Fraw/branches","groups":{"owner":"fixture-75-owner%2Fraw","name":"fixture-75-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /repos/:owner/:name/secrets","pathname":"/repos/fixture-76-owner%2Fraw/fixture-76-name%2Fraw/secrets","groups":{"owner":"fixture-76-owner%2Fraw","name":"fixture-76-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /repos/:owner/:name/secrets","pathname":"/repos/fixture-77-owner%2Fraw/fixture-77-name%2Fraw/secrets","groups":{"owner":"fixture-77-owner%2Fraw","name":"fixture-77-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /repos/:owner/:name/secrets/:key","pathname":"/repos/fixture-78-owner%2Fraw/fixture-78-name%2Fraw/secrets/fixture-78-key%2Fraw","groups":{"owner":"fixture-78-owner%2Fraw","name":"fixture-78-name%2Fraw","key":"fixture-78-key%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /repos/:owner/:name/metadata","pathname":"/repos/fixture-76-owner%2Fraw/fixture-76-name%2Fraw/metadata","groups":{"owner":"fixture-76-owner%2Fraw","name":"fixture-76-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /repos/:owner/:name/metadata","pathname":"/repos/fixture-77-owner%2Fraw/fixture-77-name%2Fraw/metadata","groups":{"owner":"fixture-77-owner%2Fraw","name":"fixture-77-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"github-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /repos/:owner/:name/branches","pathname":"/repos/fixture-78-owner%2Fraw/fixture-78-name%2Fraw/branches","groups":{"owner":"fixture-78-owner%2Fraw","name":"fixture-78-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /repos/:owner/:name/secrets","pathname":"/repos/fixture-79-owner%2Fraw/fixture-79-name%2Fraw/secrets","groups":{"owner":"fixture-79-owner%2Fraw","name":"fixture-79-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /repos/:owner/:name/secrets","pathname":"/repos/fixture-80-owner%2Fraw/fixture-80-name%2Fraw/secrets","groups":{"owner":"fixture-80-owner%2Fraw","name":"fixture-80-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /repos/:owner/:name/secrets/:key","pathname":"/repos/fixture-81-owner%2Fraw/fixture-81-name%2Fraw/secrets/fixture-81-key%2Fraw","groups":{"owner":"fixture-81-owner%2Fraw","name":"fixture-81-name%2Fraw","key":"fixture-81-key%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"PUT /secrets","pathname":"/secrets","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"global_secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /secrets","pathname":"/secrets","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"global_secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /secrets/:key","pathname":"/secrets/fixture-81-key%2Fraw","groups":{"key":"fixture-81-key%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"global_secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /secrets/:key","pathname":"/secrets/fixture-84-key%2Fraw","groups":{"key":"fixture-84-key%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"global_secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /environments","pathname":"/environments","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"},{"service":"linear-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /environments","pathname":"/environments","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /environments/:id","pathname":"/environments/fixture-84-id%2Fraw","groups":{"id":"fixture-84-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"github-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /environments/:id","pathname":"/environments/fixture-85-id%2Fraw","groups":{"id":"fixture-85-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /environments/:id","pathname":"/environments/fixture-86-id%2Fraw","groups":{"id":"fixture-86-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /environments/:id/secrets","pathname":"/environments/fixture-87-id%2Fraw/secrets","groups":{"id":"fixture-87-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /environments/:id/secrets","pathname":"/environments/fixture-88-id%2Fraw/secrets","groups":{"id":"fixture-88-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /environments/:id/secrets/import","pathname":"/environments/fixture-89-id%2Fraw/secrets/import","groups":{"id":"fixture-89-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /environments/:id/secrets/:key","pathname":"/environments/fixture-90-id%2Fraw/secrets/fixture-90-key%2Fraw","groups":{"id":"fixture-90-id%2Fraw","key":"fixture-90-key%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /environments/:id","pathname":"/environments/fixture-87-id%2Fraw","groups":{"id":"fixture-87-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"environment","idParam":"id","need":"read"}],"service":{"kind":"actor","actorlessGrants":[{"service":"github-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /environments/:id","pathname":"/environments/fixture-88-id%2Fraw","groups":{"id":"fixture-88-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"environment","idParam":"id","need":"manage"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /environments/:id","pathname":"/environments/fixture-89-id%2Fraw","groups":{"id":"fixture-89-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"environment","idParam":"id","need":"manage"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /environments/:id/secrets","pathname":"/environments/fixture-90-id%2Fraw/secrets","groups":{"id":"fixture-90-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"},{"kind":"environment","idParam":"id","need":"read"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /environments/:id/secrets","pathname":"/environments/fixture-91-id%2Fraw/secrets","groups":{"id":"fixture-91-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"},{"kind":"environment","idParam":"id","need":"manage"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /environments/:id/secrets/import","pathname":"/environments/fixture-92-id%2Fraw/secrets/import","groups":{"id":"fixture-92-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"},{"kind":"environment","idParam":"id","need":"manage"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /environments/:id/secrets/:key","pathname":"/environments/fixture-93-id%2Fraw/secrets/fixture-93-key%2Fraw","groups":{"id":"fixture-93-id%2Fraw","key":"fixture-93-key%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"},{"kind":"environment","idParam":"id","need":"manage"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /image-builds/build-complete","pathname":"/image-builds/build-complete","groups":{},"authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /image-builds/build-failed","pathname":"/image-builds/build-failed","groups":{},"authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /image-builds/trigger/environment/:id","pathname":"/image-builds/trigger/environment/fixture-93-id%2Fraw","groups":{"id":"fixture-93-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.images.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /image-builds/trigger/repo/:owner/:name","pathname":"/image-builds/trigger/repo/fixture-94-owner%2Fraw/fixture-94-name%2Fraw","groups":{"owner":"fixture-94-owner%2Fraw","name":"fixture-94-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.images.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /image-builds/toggle/repo/:owner/:name","pathname":"/image-builds/toggle/repo/fixture-95-owner%2Fraw/fixture-95-name%2Fraw","groups":{"owner":"fixture-95-owner%2Fraw","name":"fixture-95-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.images.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /image-builds/trigger/environment/:id","pathname":"/image-builds/trigger/environment/fixture-96-id%2Fraw","groups":{"id":"fixture-96-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.images.manage"},{"kind":"environment","idParam":"id","need":"manage"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /image-builds/trigger/repo/:owner/:name","pathname":"/image-builds/trigger/repo/fixture-97-owner%2Fraw/fixture-97-name%2Fraw","groups":{"owner":"fixture-97-owner%2Fraw","name":"fixture-97-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.images.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /image-builds/toggle/repo/:owner/:name","pathname":"/image-builds/toggle/repo/fixture-98-owner%2Fraw/fixture-98-name%2Fraw","groups":{"owner":"fixture-98-owner%2Fraw","name":"fixture-98-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.images.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /image-builds/status","pathname":"/image-builds/status","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"image_builds.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /image-builds/enabled","pathname":"/image-builds/enabled","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"image_builds.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /image-builds/enabled-repos","pathname":"/image-builds/enabled-repos","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"image_builds.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", @@ -107,66 +110,67 @@ exports[`Hono route catalog conformance > dispatches every frozen method/path/po "{"identity":"GET /model-provider-accounts/legacy-credentials","pathname":"/model-provider-accounts/legacy-credentials","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"GET /model-provider-accounts","pathname":"/model-provider-accounts","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"POST /model-provider-accounts","pathname":"/model-provider-accounts","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:provider/device-authorizations","pathname":"/model-provider-accounts/fixture-105-provider%2Fraw/device-authorizations","groups":{"provider":"fixture-105-provider%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:provider/device-authorizations/:id/poll","pathname":"/model-provider-accounts/fixture-106-provider%2Fraw/device-authorizations/fixture-106-id%2Fraw/poll","groups":{"provider":"fixture-106-provider%2Fraw","id":"fixture-106-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"DELETE /model-provider-accounts/:provider/device-authorizations/:id","pathname":"/model-provider-accounts/fixture-107-provider%2Fraw/device-authorizations/fixture-107-id%2Fraw","groups":{"provider":"fixture-107-provider%2Fraw","id":"fixture-107-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:provider/authorization-codes","pathname":"/model-provider-accounts/fixture-108-provider%2Fraw/authorization-codes","groups":{"provider":"fixture-108-provider%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"GET /model-provider-accounts/:provider/authorization-codes/:id","pathname":"/model-provider-accounts/fixture-109-provider%2Fraw/authorization-codes/fixture-109-id%2Fraw","groups":{"provider":"fixture-109-provider%2Fraw","id":"fixture-109-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:provider/authorization-codes/:id/complete","pathname":"/model-provider-accounts/fixture-110-provider%2Fraw/authorization-codes/fixture-110-id%2Fraw/complete","groups":{"provider":"fixture-110-provider%2Fraw","id":"fixture-110-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"DELETE /model-provider-accounts/:provider/authorization-codes/:id","pathname":"/model-provider-accounts/fixture-111-provider%2Fraw/authorization-codes/fixture-111-id%2Fraw","groups":{"provider":"fixture-111-provider%2Fraw","id":"fixture-111-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"GET /model-provider-accounts/:id","pathname":"/model-provider-accounts/fixture-112-id%2Fraw","groups":{"id":"fixture-112-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"PATCH /model-provider-accounts/:id","pathname":"/model-provider-accounts/fixture-113-id%2Fraw","groups":{"id":"fixture-113-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:id/verify","pathname":"/model-provider-accounts/fixture-114-id%2Fraw/verify","groups":{"id":"fixture-114-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:id/disable","pathname":"/model-provider-accounts/fixture-115-id%2Fraw/disable","groups":{"id":"fixture-115-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:id/enable","pathname":"/model-provider-accounts/fixture-116-id%2Fraw/enable","groups":{"id":"fixture-116-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:id/reconnect","pathname":"/model-provider-accounts/fixture-117-id%2Fraw/reconnect","groups":{"id":"fixture-117-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"DELETE /model-provider-accounts/:id","pathname":"/model-provider-accounts/fixture-118-id%2Fraw","groups":{"id":"fixture-118-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:provider/device-authorizations","pathname":"/model-provider-accounts/fixture-108-provider%2Fraw/device-authorizations","groups":{"provider":"fixture-108-provider%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:provider/device-authorizations/:id/poll","pathname":"/model-provider-accounts/fixture-109-provider%2Fraw/device-authorizations/fixture-109-id%2Fraw/poll","groups":{"provider":"fixture-109-provider%2Fraw","id":"fixture-109-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"DELETE /model-provider-accounts/:provider/device-authorizations/:id","pathname":"/model-provider-accounts/fixture-110-provider%2Fraw/device-authorizations/fixture-110-id%2Fraw","groups":{"provider":"fixture-110-provider%2Fraw","id":"fixture-110-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:provider/authorization-codes","pathname":"/model-provider-accounts/fixture-111-provider%2Fraw/authorization-codes","groups":{"provider":"fixture-111-provider%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /model-provider-accounts/:provider/authorization-codes/:id","pathname":"/model-provider-accounts/fixture-112-provider%2Fraw/authorization-codes/fixture-112-id%2Fraw","groups":{"provider":"fixture-112-provider%2Fraw","id":"fixture-112-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:provider/authorization-codes/:id/complete","pathname":"/model-provider-accounts/fixture-113-provider%2Fraw/authorization-codes/fixture-113-id%2Fraw/complete","groups":{"provider":"fixture-113-provider%2Fraw","id":"fixture-113-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"DELETE /model-provider-accounts/:provider/authorization-codes/:id","pathname":"/model-provider-accounts/fixture-114-provider%2Fraw/authorization-codes/fixture-114-id%2Fraw","groups":{"provider":"fixture-114-provider%2Fraw","id":"fixture-114-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /model-provider-accounts/:id","pathname":"/model-provider-accounts/fixture-115-id%2Fraw","groups":{"id":"fixture-115-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"PATCH /model-provider-accounts/:id","pathname":"/model-provider-accounts/fixture-116-id%2Fraw","groups":{"id":"fixture-116-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:id/verify","pathname":"/model-provider-accounts/fixture-117-id%2Fraw/verify","groups":{"id":"fixture-117-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:id/disable","pathname":"/model-provider-accounts/fixture-118-id%2Fraw/disable","groups":{"id":"fixture-118-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:id/enable","pathname":"/model-provider-accounts/fixture-119-id%2Fraw/enable","groups":{"id":"fixture-119-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:id/reconnect","pathname":"/model-provider-accounts/fixture-120-id%2Fraw/reconnect","groups":{"id":"fixture-120-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"DELETE /model-provider-accounts/:id","pathname":"/model-provider-accounts/fixture-121-id%2Fraw","groups":{"id":"fixture-121-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"GET /model-provider-account-defaults","pathname":"/model-provider-account-defaults","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"PUT /model-provider-account-defaults/:provider","pathname":"/model-provider-account-defaults/fixture-120-provider%2Fraw","groups":{"provider":"fixture-120-provider%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"DELETE /model-provider-account-defaults/:provider","pathname":"/model-provider-account-defaults/fixture-121-provider%2Fraw","groups":{"provider":"fixture-121-provider%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/provider-auth/:provider/access-token","pathname":"/sessions/fixture-122-id%2Fraw/provider-auth/fixture-122-provider%2Fraw/access-token","groups":{"id":"fixture-122-id%2Fraw","provider":"fixture-122-provider%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"no-store","hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/provider-auth/:provider/runtime-credential","pathname":"/sessions/fixture-123-id%2Fraw/provider-auth/fixture-123-provider%2Fraw/runtime-credential","groups":{"id":"fixture-123-id%2Fraw","provider":"fixture-123-provider%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"no-store","hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/review-token","pathname":"/sessions/fixture-124-id%2Fraw/review-token","groups":{"id":"fixture-124-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"no-store","hasServiceActorClaims":false}", - "{"identity":"GET /integration-settings/:id","pathname":"/integration-settings/fixture-125-id%2Fraw","groups":{"id":"fixture-125-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot","pathParams":{"id":"slack"}}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /integration-settings/:id","pathname":"/integration-settings/fixture-126-id%2Fraw","groups":{"id":"fixture-126-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /integration-settings/:id","pathname":"/integration-settings/fixture-127-id%2Fraw","groups":{"id":"fixture-127-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /integration-settings/:id/repos","pathname":"/integration-settings/fixture-128-id%2Fraw/repos","groups":{"id":"fixture-128-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /integration-settings/:id/repos/:owner/:name","pathname":"/integration-settings/fixture-129-id%2Fraw/repos/fixture-129-owner%2Fraw/fixture-129-name%2Fraw","groups":{"id":"fixture-129-id%2Fraw","owner":"fixture-129-owner%2Fraw","name":"fixture-129-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /integration-settings/:id/repos/:owner/:name","pathname":"/integration-settings/fixture-130-id%2Fraw/repos/fixture-130-owner%2Fraw/fixture-130-name%2Fraw","groups":{"id":"fixture-130-id%2Fraw","owner":"fixture-130-owner%2Fraw","name":"fixture-130-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /integration-settings/:id/repos/:owner/:name","pathname":"/integration-settings/fixture-131-id%2Fraw/repos/fixture-131-owner%2Fraw/fixture-131-name%2Fraw","groups":{"id":"fixture-131-id%2Fraw","owner":"fixture-131-owner%2Fraw","name":"fixture-131-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /integration-settings/:id/environments/:environmentId","pathname":"/integration-settings/fixture-132-id%2Fraw/environments/fixture-132-environmentId%2Fraw","groups":{"id":"fixture-132-id%2Fraw","environmentId":"fixture-132-environmentId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /integration-settings/:id/environments/:environmentId","pathname":"/integration-settings/fixture-133-id%2Fraw/environments/fixture-133-environmentId%2Fraw","groups":{"id":"fixture-133-id%2Fraw","environmentId":"fixture-133-environmentId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /integration-settings/:id/environments/:environmentId","pathname":"/integration-settings/fixture-134-id%2Fraw/environments/fixture-134-environmentId%2Fraw","groups":{"id":"fixture-134-id%2Fraw","environmentId":"fixture-134-environmentId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /integration-settings/:id/resolved/:owner/:name","pathname":"/integration-settings/fixture-135-id%2Fraw/resolved/fixture-135-owner%2Fraw/fixture-135-name%2Fraw","groups":{"id":"fixture-135-id%2Fraw","owner":"fixture-135-owner%2Fraw","name":"fixture-135-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"github-bot","pathParams":{"id":"github"}},{"service":"linear-bot","pathParams":{"id":"linear"}}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /model-provider-account-defaults/:provider","pathname":"/model-provider-account-defaults/fixture-123-provider%2Fraw","groups":{"provider":"fixture-123-provider%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"DELETE /model-provider-account-defaults/:provider","pathname":"/model-provider-account-defaults/fixture-124-provider%2Fraw","groups":{"provider":"fixture-124-provider%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/provider-auth/:provider/access-token","pathname":"/sessions/fixture-125-id%2Fraw/provider-auth/fixture-125-provider%2Fraw/access-token","groups":{"id":"fixture-125-id%2Fraw","provider":"fixture-125-provider%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"no-store","hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/provider-auth/:provider/runtime-credential","pathname":"/sessions/fixture-126-id%2Fraw/provider-auth/fixture-126-provider%2Fraw/runtime-credential","groups":{"id":"fixture-126-id%2Fraw","provider":"fixture-126-provider%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"no-store","hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/review-token","pathname":"/sessions/fixture-127-id%2Fraw/review-token","groups":{"id":"fixture-127-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"no-store","hasServiceActorClaims":false}", + "{"identity":"GET /integration-settings/:id","pathname":"/integration-settings/fixture-128-id%2Fraw","groups":{"id":"fixture-128-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot","pathParams":{"id":"slack"}}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /integration-settings/:id","pathname":"/integration-settings/fixture-129-id%2Fraw","groups":{"id":"fixture-129-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /integration-settings/:id","pathname":"/integration-settings/fixture-130-id%2Fraw","groups":{"id":"fixture-130-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /integration-settings/:id/repos","pathname":"/integration-settings/fixture-131-id%2Fraw/repos","groups":{"id":"fixture-131-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /integration-settings/:id/repos/:owner/:name","pathname":"/integration-settings/fixture-132-id%2Fraw/repos/fixture-132-owner%2Fraw/fixture-132-name%2Fraw","groups":{"id":"fixture-132-id%2Fraw","owner":"fixture-132-owner%2Fraw","name":"fixture-132-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /integration-settings/:id/repos/:owner/:name","pathname":"/integration-settings/fixture-133-id%2Fraw/repos/fixture-133-owner%2Fraw/fixture-133-name%2Fraw","groups":{"id":"fixture-133-id%2Fraw","owner":"fixture-133-owner%2Fraw","name":"fixture-133-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /integration-settings/:id/repos/:owner/:name","pathname":"/integration-settings/fixture-134-id%2Fraw/repos/fixture-134-owner%2Fraw/fixture-134-name%2Fraw","groups":{"id":"fixture-134-id%2Fraw","owner":"fixture-134-owner%2Fraw","name":"fixture-134-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /integration-settings/:id/environments/:environmentId","pathname":"/integration-settings/fixture-135-id%2Fraw/environments/fixture-135-environmentId%2Fraw","groups":{"id":"fixture-135-id%2Fraw","environmentId":"fixture-135-environmentId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"},{"kind":"environment","idParam":"environmentId","need":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /integration-settings/:id/environments/:environmentId","pathname":"/integration-settings/fixture-136-id%2Fraw/environments/fixture-136-environmentId%2Fraw","groups":{"id":"fixture-136-id%2Fraw","environmentId":"fixture-136-environmentId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.settings.manage"},{"kind":"environment","idParam":"environmentId","need":"manage"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /integration-settings/:id/environments/:environmentId","pathname":"/integration-settings/fixture-137-id%2Fraw/environments/fixture-137-environmentId%2Fraw","groups":{"id":"fixture-137-id%2Fraw","environmentId":"fixture-137-environmentId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.settings.manage"},{"kind":"environment","idParam":"environmentId","need":"manage"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /integration-settings/:id/resolved/:owner/:name","pathname":"/integration-settings/fixture-138-id%2Fraw/resolved/fixture-138-owner%2Fraw/fixture-138-name%2Fraw","groups":{"id":"fixture-138-id%2Fraw","owner":"fixture-138-owner%2Fraw","name":"fixture-138-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"github-bot","pathParams":{"id":"github"}},{"service":"linear-bot","pathParams":{"id":"linear"}}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /commit-signing","pathname":"/commit-signing","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"PUT /commit-signing","pathname":"/commit-signing","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"commit_signing.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"DELETE /commit-signing","pathname":"/commit-signing","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"commit_signing.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/commit-signing","pathname":"/sessions/fixture-139-id%2Fraw/commit-signing","groups":{"id":"fixture-139-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/commit-signing","pathname":"/sessions/fixture-140-id%2Fraw/commit-signing","groups":{"id":"fixture-140-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/commit-signing","pathname":"/sessions/fixture-142-id%2Fraw/commit-signing","groups":{"id":"fixture-142-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/commit-signing","pathname":"/sessions/fixture-143-id%2Fraw/commit-signing","groups":{"id":"fixture-143-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /scm-settings","pathname":"/scm-settings","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"PUT /scm-settings","pathname":"/scm-settings","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"scm_settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"DELETE /scm-settings","pathname":"/scm-settings","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"scm_settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /scm-settings/repos","pathname":"/scm-settings/repos","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /scm-settings/repos/:owner/:name","pathname":"/scm-settings/repos/fixture-145-owner%2Fraw/fixture-145-name%2Fraw","groups":{"owner":"fixture-145-owner%2Fraw","name":"fixture-145-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"scm_settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /scm-settings/repos/:owner/:name","pathname":"/scm-settings/repos/fixture-146-owner%2Fraw/fixture-146-name%2Fraw","groups":{"owner":"fixture-146-owner%2Fraw","name":"fixture-146-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"scm_settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /scm-settings/repos/:owner/:name","pathname":"/scm-settings/repos/fixture-148-owner%2Fraw/fixture-148-name%2Fraw","groups":{"owner":"fixture-148-owner%2Fraw","name":"fixture-148-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"scm_settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /scm-settings/repos/:owner/:name","pathname":"/scm-settings/repos/fixture-149-owner%2Fraw/fixture-149-name%2Fraw","groups":{"owner":"fixture-149-owner%2Fraw","name":"fixture-149-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"scm_settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /integration-settings/slack/watched-channels","pathname":"/integration-settings/slack/watched-channels","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /integration-settings/slack/channels","pathname":"/integration-settings/slack/channels","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /automations","pathname":"/automations","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /integration-settings/slack/channels","pathname":"/integration-settings/slack/channels","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /automations","pathname":"/automations","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /automations","pathname":"/automations","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.create"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /automations/:id","pathname":"/automations/fixture-151-id%2Fraw","groups":{"id":"fixture-151-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /automations/:id","pathname":"/automations/fixture-152-id%2Fraw","groups":{"id":"fixture-152-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /automations/:id","pathname":"/automations/fixture-153-id%2Fraw","groups":{"id":"fixture-153-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /automations/:id/pause","pathname":"/automations/fixture-154-id%2Fraw/pause","groups":{"id":"fixture-154-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /automations/:id/resume","pathname":"/automations/fixture-155-id%2Fraw/resume","groups":{"id":"fixture-155-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /automations/:id/trigger","pathname":"/automations/fixture-156-id%2Fraw/trigger","groups":{"id":"fixture-156-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"trigger","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /automations/:id/invocations","pathname":"/automations/fixture-157-id%2Fraw/invocations","groups":{"id":"fixture-157-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /automations/:id/runs/:runId","pathname":"/automations/fixture-158-id%2Fraw/runs/fixture-158-runId%2Fraw","groups":{"id":"fixture-158-id%2Fraw","runId":"fixture-158-runId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /automations/:id/regenerate-key","pathname":"/automations/fixture-159-id%2Fraw/regenerate-key","groups":{"id":"fixture-159-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":"no-store","hasServiceActorClaims":false}", + "{"identity":"GET /automations/:id","pathname":"/automations/fixture-154-id%2Fraw","groups":{"id":"fixture-154-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"read","automationIdParam":"id"}],"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /automations/:id","pathname":"/automations/fixture-155-id%2Fraw","groups":{"id":"fixture-155-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /automations/:id","pathname":"/automations/fixture-156-id%2Fraw","groups":{"id":"fixture-156-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PATCH /automations/:id","pathname":"/automations/fixture-157-id%2Fraw","groups":{"id":"fixture-157-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /automations/:id/pause","pathname":"/automations/fixture-158-id%2Fraw/pause","groups":{"id":"fixture-158-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /automations/:id/resume","pathname":"/automations/fixture-159-id%2Fraw/resume","groups":{"id":"fixture-159-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /automations/:id/trigger","pathname":"/automations/fixture-160-id%2Fraw/trigger","groups":{"id":"fixture-160-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"trigger","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /automations/:id/invocations","pathname":"/automations/fixture-161-id%2Fraw/invocations","groups":{"id":"fixture-161-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"read","automationIdParam":"id"}],"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /automations/:id/runs/:runId","pathname":"/automations/fixture-162-id%2Fraw/runs/fixture-162-runId%2Fraw","groups":{"id":"fixture-162-id%2Fraw","runId":"fixture-162-runId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"read","automationIdParam":"id"}],"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /automations/:id/regenerate-key","pathname":"/automations/fixture-163-id%2Fraw/regenerate-key","groups":{"id":"fixture-163-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":"no-store","hasServiceActorClaims":false}", "{"identity":"GET /mcp-servers","pathname":"/mcp-servers","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /mcp-servers","pathname":"/mcp-servers","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /mcp-servers/:id","pathname":"/mcp-servers/fixture-162-id%2Fraw","groups":{"id":"fixture-162-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /mcp-servers/:id","pathname":"/mcp-servers/fixture-163-id%2Fraw","groups":{"id":"fixture-163-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /mcp-servers/:id","pathname":"/mcp-servers/fixture-164-id%2Fraw","groups":{"id":"fixture-164-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /mcp-servers/:id","pathname":"/mcp-servers/fixture-166-id%2Fraw","groups":{"id":"fixture-166-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /mcp-servers/:id","pathname":"/mcp-servers/fixture-167-id%2Fraw","groups":{"id":"fixture-167-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /mcp-servers/:id","pathname":"/mcp-servers/fixture-168-id%2Fraw","groups":{"id":"fixture-168-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /analytics/dashboard","pathname":"/analytics/dashboard","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"analytics.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /analytics/summary","pathname":"/analytics/summary","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"analytics.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /analytics/timeseries","pathname":"/analytics/timeseries","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"analytics.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", @@ -178,35 +182,35 @@ exports[`Hono route catalog conformance > dispatches every frozen method/path/po "{"identity":"GET /skills","pathname":"/skills","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /skills/preview","pathname":"/skills/preview","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /skills/resolve-preview","pathname":"/skills/resolve-preview","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /skills/:id","pathname":"/skills/fixture-176-id%2Fraw","groups":{"id":"fixture-176-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /skills/:id","pathname":"/skills/fixture-180-id%2Fraw","groups":{"id":"fixture-180-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /skills","pathname":"/skills","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /skills/import/preview","pathname":"/skills/import/preview","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /skills/import","pathname":"/skills/import","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /skills/:id/reimport/preview","pathname":"/skills/fixture-180-id%2Fraw/reimport/preview","groups":{"id":"fixture-180-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /skills/:id/reimport","pathname":"/skills/fixture-181-id%2Fraw/reimport","groups":{"id":"fixture-181-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PATCH /skills/:id","pathname":"/skills/fixture-182-id%2Fraw","groups":{"id":"fixture-182-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /skills/:id","pathname":"/skills/fixture-183-id%2Fraw","groups":{"id":"fixture-183-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /skills/:id","pathname":"/skills/fixture-184-id%2Fraw","groups":{"id":"fixture-184-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /skills/:id/reimport/preview","pathname":"/skills/fixture-184-id%2Fraw/reimport/preview","groups":{"id":"fixture-184-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /skills/:id/reimport","pathname":"/skills/fixture-185-id%2Fraw/reimport","groups":{"id":"fixture-185-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PATCH /skills/:id","pathname":"/skills/fixture-186-id%2Fraw","groups":{"id":"fixture-186-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /skills/:id","pathname":"/skills/fixture-187-id%2Fraw","groups":{"id":"fixture-187-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /skills/:id","pathname":"/skills/fixture-188-id%2Fraw","groups":{"id":"fixture-188-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /skill-profiles","pathname":"/skill-profiles","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"POST /skill-profiles","pathname":"/skill-profiles","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PATCH /skill-profiles/:id","pathname":"/skill-profiles/fixture-187-id%2Fraw","groups":{"id":"fixture-187-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /skill-profiles/:id","pathname":"/skill-profiles/fixture-188-id%2Fraw","groups":{"id":"fixture-188-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PATCH /skill-profiles/:id","pathname":"/skill-profiles/fixture-191-id%2Fraw","groups":{"id":"fixture-191-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /skill-profiles/:id","pathname":"/skill-profiles/fixture-192-id%2Fraw","groups":{"id":"fixture-192-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /keyboard-shortcuts","pathname":"/keyboard-shortcuts","groups":{},"authentication":"user","authorization":{"kind":"active-self","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"PUT /keyboard-shortcuts","pathname":"/keyboard-shortcuts","groups":{},"authentication":"user","authorization":{"kind":"active-self","auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /me/authorization","pathname":"/me/authorization","groups":{},"authentication":"user","authorization":{"kind":"authenticated","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"GET /roles","pathname":"/roles","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.roles.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"GET /roles/:id","pathname":"/roles/fixture-193-id%2Fraw","groups":{"id":"fixture-193-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.roles.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /roles/:id","pathname":"/roles/fixture-197-id%2Fraw","groups":{"id":"fixture-197-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.roles.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"GET /members","pathname":"/members","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"PUT /members/:id/role","pathname":"/members/fixture-195-id%2Fraw/role","groups":{"id":"fixture-195-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"PUT /members/:id/status","pathname":"/members/fixture-196-id%2Fraw/status","groups":{"id":"fixture-196-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"PUT /members/:id/role","pathname":"/members/fixture-199-id%2Fraw/role","groups":{"id":"fixture-199-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"PUT /members/:id/status","pathname":"/members/fixture-200-id%2Fraw/status","groups":{"id":"fixture-200-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"GET /access-tokens","pathname":"/access-tokens","groups":{},"authentication":"user","authorization":{"kind":"active-self","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"POST /access-tokens","pathname":"/access-tokens","groups":{},"authentication":"user","authorization":{"kind":"active-self","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"DELETE /access-tokens/:id","pathname":"/access-tokens/fixture-199-id%2Fraw","groups":{"id":"fixture-199-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-self","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"GET /teams/:id/secrets","pathname":"/teams/fixture-200-id%2Fraw/secrets","groups":{"id":"fixture-200-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageSecrets"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"PUT /teams/:id/secrets","pathname":"/teams/fixture-201-id%2Fraw/secrets","groups":{"id":"fixture-201-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageSecrets"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"DELETE /teams/:id/secrets/:key","pathname":"/teams/fixture-202-id%2Fraw/secrets/fixture-202-key%2Fraw","groups":{"id":"fixture-202-id%2Fraw","key":"fixture-202-key%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageSecrets"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /webhooks/sentry/:id","pathname":"/webhooks/sentry/fixture-203-id%2Fraw","groups":{"id":"fixture-203-id%2Fraw"},"authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /webhooks/automation/:id","pathname":"/webhooks/automation/fixture-204-id%2Fraw","groups":{"id":"fixture-204-id%2Fraw"},"authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /access-tokens/:id","pathname":"/access-tokens/fixture-203-id%2Fraw","groups":{"id":"fixture-203-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-self","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /teams/:id/secrets","pathname":"/teams/fixture-204-id%2Fraw/secrets","groups":{"id":"fixture-204-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageSecrets"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"PUT /teams/:id/secrets","pathname":"/teams/fixture-205-id%2Fraw/secrets","groups":{"id":"fixture-205-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageSecrets"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"DELETE /teams/:id/secrets/:key","pathname":"/teams/fixture-206-id%2Fraw/secrets/fixture-206-key%2Fraw","groups":{"id":"fixture-206-id%2Fraw","key":"fixture-206-key%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageSecrets"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /webhooks/sentry/:id","pathname":"/webhooks/sentry/fixture-207-id%2Fraw","groups":{"id":"fixture-207-id%2Fraw"},"authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /webhooks/automation/:id","pathname":"/webhooks/automation/fixture-208-id%2Fraw","groups":{"id":"fixture-208-id%2Fraw"},"authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /internal/github-event","pathname":"/internal/github-event","groups":{},"authentication":"service","authorization":{"kind":"service","services":["github-bot"],"actor":"optional","auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /internal/slack-event","pathname":"/internal/slack-event","groups":{},"authentication":"service","authorization":{"kind":"service","services":["slack-bot"],"actor":"optional","auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /internal/github-reviews/claim","pathname":"/internal/github-reviews/claim","groups":{},"authentication":"service","authorization":{"kind":"service","services":["github-bot"],"actor":"optional","auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", @@ -216,7 +220,7 @@ exports[`Hono route catalog conformance > dispatches every frozen method/path/po "{"identity":"POST /internal/github-reviews/start-marker/release","pathname":"/internal/github-reviews/start-marker/release","groups":{},"authentication":"service","authorization":{"kind":"service","services":["github-bot"],"actor":"optional","auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /internal/github-reviews/close-out","pathname":"/internal/github-reviews/close-out","groups":{},"authentication":"service","authorization":{"kind":"service","services":["github-bot"],"actor":"optional","auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /internal/github-reviews/close-out/finalize","pathname":"/internal/github-reviews/close-out/finalize","groups":{},"authentication":"service","authorization":{"kind":"service","services":["github-bot"],"actor":"optional","auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/review-ownership","pathname":"/sessions/fixture-214-id%2Fraw/review-ownership","groups":{"id":"fixture-214-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /sessions/:id/review-ownership","pathname":"/sessions/fixture-215-id%2Fraw/review-ownership","groups":{"id":"fixture-215-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/review-ownership","pathname":"/sessions/fixture-218-id%2Fraw/review-ownership","groups":{"id":"fixture-218-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /sessions/:id/review-ownership","pathname":"/sessions/fixture-219-id%2Fraw/review-ownership","groups":{"id":"fixture-219-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", ] `; diff --git a/packages/control-plane/test/integration/__snapshots__/route-admission-matrix.test.ts.snap b/packages/control-plane/test/integration/__snapshots__/route-admission-matrix.test.ts.snap index 2ea5001d9b..48f8e74908 100644 --- a/packages/control-plane/test/integration/__snapshots__/route-admission-matrix.test.ts.snap +++ b/packages/control-plane/test/integration/__snapshots__/route-admission-matrix.test.ts.snap @@ -63,6 +63,9 @@ exports[`route admission matrix > admits the workspace owner through every brows "DELETE /teams/:id/members/:userId owner=404", "POST /teams/:id/join owner=200", "GET /teams/:id/sessions owner=200", + "GET /teams/:id/repository-grants owner=200", + "PUT /teams/:id/repository-grants owner=400", + "DELETE /teams/:id/repository-grants/:grantId owner=404", "GET /settings/teams owner=200", "PATCH /settings/teams owner=400", "POST /sessions owner=201", @@ -128,8 +131,8 @@ exports[`route admission matrix > admits the workspace owner through every brows "PUT /environments/:id/secrets owner=404", "POST /environments/:id/secrets/import owner=404", "DELETE /environments/:id/secrets/:key owner=404", - "POST /image-builds/trigger/environment/:id owner=503", - "POST /image-builds/trigger/repo/:owner/:name owner=503", + "POST /image-builds/trigger/environment/:id owner=404", + "POST /image-builds/trigger/repo/:owner/:name owner=500", "PUT /image-builds/toggle/repo/:owner/:name owner=400", "GET /image-builds/status owner=200", "GET /image-builds/enabled owner=200", @@ -184,6 +187,7 @@ exports[`route admission matrix > admits the workspace owner through every brows "GET /automations/:id owner=200", "PUT /automations/:id owner=200", "DELETE /automations/:id owner=200", + "PATCH /automations/:id owner=400", "POST /automations/:id/pause owner=200", "POST /automations/:id/resume owner=200", "POST /automations/:id/trigger owner=201", @@ -258,6 +262,9 @@ exports[`route admission matrix > rejects every credentialed route anonymously b "DELETE /teams/:id/members/:userId anonymous=401", "POST /teams/:id/join anonymous=401", "GET /teams/:id/sessions anonymous=401", + "GET /teams/:id/repository-grants anonymous=401", + "PUT /teams/:id/repository-grants anonymous=401", + "DELETE /teams/:id/repository-grants/:grantId anonymous=401", "GET /settings/teams anonymous=401", "PATCH /settings/teams anonymous=401", "POST /sessions anonymous=401", @@ -392,6 +399,7 @@ exports[`route admission matrix > rejects every credentialed route anonymously b "GET /automations/:id anonymous=401", "PUT /automations/:id anonymous=401", "DELETE /automations/:id anonymous=401", + "PATCH /automations/:id anonymous=401", "POST /automations/:id/pause anonymous=401", "POST /automations/:id/resume anonymous=401", "POST /automations/:id/trigger anonymous=401", @@ -476,6 +484,9 @@ exports[`route admission sentinel > admits team directory reads but conceals mem "DELETE /teams/:id/members/:userId off/nonmember=404 auditAllowed=true", "POST /teams/:id/join off/nonmember=200 auditAllowed=true", "GET /teams/:id/sessions off/nonmember=404 auditAllowed=false", + "GET /teams/:id/repository-grants off/nonmember=404 auditAllowed=false", + "PUT /teams/:id/repository-grants off/nonmember=403 auditAllowed=true", + "DELETE /teams/:id/repository-grants/:grantId off/nonmember=403 auditAllowed=true", "GET /teams/:id/secrets off/nonmember=403 auditAllowed=true", "PUT /teams/:id/secrets off/nonmember=403 auditAllowed=true", "DELETE /teams/:id/secrets/:key off/nonmember=403 auditAllowed=true", @@ -488,6 +499,9 @@ exports[`route admission sentinel > admits team directory reads but conceals mem "DELETE /teams/:id/members/:userId shadow/nonmember=404 auditAllowed=true", "POST /teams/:id/join shadow/nonmember=200 auditAllowed=true", "GET /teams/:id/sessions shadow/nonmember=404 auditAllowed=false", + "GET /teams/:id/repository-grants shadow/nonmember=404 auditAllowed=false", + "PUT /teams/:id/repository-grants shadow/nonmember=403 auditAllowed=true", + "DELETE /teams/:id/repository-grants/:grantId shadow/nonmember=403 auditAllowed=true", "GET /teams/:id/secrets shadow/nonmember=403 auditAllowed=true", "PUT /teams/:id/secrets shadow/nonmember=403 auditAllowed=true", "DELETE /teams/:id/secrets/:key shadow/nonmember=403 auditAllowed=true", @@ -500,6 +514,9 @@ exports[`route admission sentinel > admits team directory reads but conceals mem "DELETE /teams/:id/members/:userId on/nonmember=404 auditAllowed=true", "POST /teams/:id/join on/nonmember=200 auditAllowed=true", "GET /teams/:id/sessions on/nonmember=404 auditAllowed=false", + "GET /teams/:id/repository-grants on/nonmember=404 auditAllowed=false", + "PUT /teams/:id/repository-grants on/nonmember=403 auditAllowed=true", + "DELETE /teams/:id/repository-grants/:grantId on/nonmember=403 auditAllowed=true", "GET /teams/:id/secrets on/nonmember=403 auditAllowed=true", "PUT /teams/:id/secrets on/nonmember=403 auditAllowed=true", "DELETE /teams/:id/secrets/:key on/nonmember=403 auditAllowed=true", diff --git a/packages/control-plane/test/integration/automation-authorization.test.ts b/packages/control-plane/test/integration/automation-authorization.test.ts index 02c2247daa..325b1a0dc3 100644 --- a/packages/control-plane/test/integration/automation-authorization.test.ts +++ b/packages/control-plane/test/integration/automation-authorization.test.ts @@ -2,6 +2,9 @@ import { env } from "cloudflare:test"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { AutomationStore, type AutomationRow } from "../../src/db/automation-store"; import { UserStore } from "../../src/db/user-store"; +import { EnvironmentStore } from "../../src/db/environments"; +import { TeamStore } from "../../src/db/teams"; +import { TeamRepositoryGrantStore } from "../../src/db/team-repository-grants"; import { cleanD1Tables } from "./cleanup"; import { serviceFetch, sqlDatabase } from "./helpers"; import { DEFAULT_AUTOMATION_MAX_CONCURRENT_RUNS } from "@open-inspect/shared/types/automations"; @@ -93,8 +96,8 @@ describe("automation router authorization", () => { expect(own.status).toBe(200); expect(denied.status).toBe(403); await expect(denied.json()).resolves.toMatchObject({ - code: "permission_required", - permission: "automations.manage.own", + code: "automation_action_denied", + reason_code: "not_owner_or_lead", }); expect((await store.getById("other-automation"))?.name).toBe("other-automation"); }); @@ -134,8 +137,8 @@ describe("automation router authorization", () => { expect(response.status).toBe(403); await expect(response.json()).resolves.toMatchObject({ - code: "permission_required", - permission: "automations.manage.own", + code: "automation_action_denied", + reason_code: "missing_permission", }); }); @@ -170,6 +173,65 @@ describe("automation router authorization", () => { }); }); + it("rejects a later environment containing an ungranted repository without changing the automation", async () => { + await seedBrowser("owner"); + const team = await new TeamStore(env.DB).create({ + slug: "automation-targets", + name: "Automation targets", + joinPolicy: "invite_only", + }); + const environments = new EnvironmentStore(env.DB); + const grants = new TeamRepositoryGrantStore(env.DB); + for (const [id, name, repoId] of [ + ["env_original", "original", 1], + ["env_allowed", "web", 2], + ["env_denied", "api", 3], + ] as const) { + await environments.create( + { + id, + name: id, + owner_team_id: team.id, + description: null, + prebuild_enabled: 0, + channel_associations: null, + created_at: 1, + updated_at: 1, + }, + [{ position: 0, repo_owner: "acme", repo_name: name, repo_id: repoId, base_branch: "main" }] + ); + if (repoId !== 3) + await grants.add(team.id, { + kind: "repository", + repoExternalId: repoId, + owner: "acme", + name, + }); + } + const store = new AutomationStore(env.DB); + const row = { + ...automation("team-environment-targets", BROWSER_USER_ID), + owner_team_id: team.id, + }; + await store.create(row); + await sqlDatabase(env.DB).batch(store.bindEnvironmentInserts(row.id, ["env_original"], 1)); + + const response = await serviceFetch(`https://cp.test/automations/${row.id}`, { + method: "PUT", + body: JSON.stringify({ name: "Updated", environmentIds: ["env_allowed", "env_denied"] }), + }); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + code: "target_team_missing_grant", + repository: "acme/api", + }); + expect(await store.getById(row.id)).toEqual(row); + expect( + (await store.getEnvironmentsForAutomation(row.id)).map((member) => member.environment_id) + ).toEqual(["env_original"]); + }); + it("denies bot services before handler dispatch", async () => { await seedBrowser("owner"); await new AutomationStore(env.DB).create(automation("service-target", BROWSER_USER_ID)); diff --git a/packages/control-plane/test/integration/automation-executor-permissions.test.ts b/packages/control-plane/test/integration/automation-executor-permissions.test.ts new file mode 100644 index 0000000000..2138ffdf35 --- /dev/null +++ b/packages/control-plane/test/integration/automation-executor-permissions.test.ts @@ -0,0 +1,286 @@ +import { env } from "cloudflare:test"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { PermissionId } from "@open-inspect/shared/rbac"; +import { AutomationStore, type AutomationRow } from "../../src/db/automation-store"; +import { cleanD1Tables } from "./cleanup"; +import { seedActiveUser, serviceFetch, sqlDatabase } from "./helpers"; +import { + assignCustomRole as customRole, + seedEnvironment, + seedTeam, +} from "./ownership-test-helpers"; + +const EXECUTOR = "11111111111111111111111111111111"; +const LEAD = "22222222222222222222222222222222"; +const CANDIDATE = "33333333333333333333333333333333"; +const TEAM = "team_executor_permissions"; +const ENVIRONMENT = "env_executor_permissions"; +const REPOSITORY = { + repo_owner: "acme/group", + repo_name: "direct", + repo_id: 701, + base_branch: "main", +}; +const LAUNCH_PERMISSIONS: PermissionId[] = [ + "sessions.create", + "repositories.use", + "environments.use", +]; +const TARGETS = [ + { target: "repoless", permissions: ["sessions.create"] }, + { target: "direct", permissions: ["sessions.create", "repositories.use"] }, + { target: "environment", permissions: ["sessions.create", "environments.use"] }, + { target: "mixed", permissions: LAUNCH_PERMISSIONS }, +] satisfies Array<{ target: string; permissions: PermissionId[] }>; + +function patch(userId: string, callerId = LEAD) { + return serviceFetch("https://cp.test/automations/executor-permissions", { + as: { userId: callerId, role: "member" }, + method: "PATCH", + body: JSON.stringify({ userId }), + }); +} + +async function saveAutomation(target: string) { + const store = new AutomationStore(env.DB); + await env.DB.prepare( + `INSERT INTO automations + (id, owner_team_id, name, instructions, schedule_cron, model, created_by, user_id, created_at, updated_at) + VALUES ('executor-permissions', ?, 'Saved executor permissions', 'Run tests', '0 9 * * *', + 'anthropic/claude-sonnet-4-6', ?, ?, 1, 1)` + ) + .bind(TEAM, EXECUTOR, EXECUTOR) + .run(); + if (target === "environment" || target === "mixed") { + await seedEnvironment(ENVIRONMENT, TEAM, [ + { ...REPOSITORY, position: 0, repo_name: "environment-member", repo_id: 702 }, + ]); + } + const statements = [ + ...store.bindRepositoryInserts( + "executor-permissions", + target === "direct" || target === "mixed" ? [REPOSITORY] : [], + 1 + ), + ...store.bindEnvironmentInserts( + "executor-permissions", + target === "environment" || target === "mixed" ? [ENVIRONMENT] : [], + 1 + ), + ]; + if (statements.length > 0) await sqlDatabase(env.DB).batch(statements); + return (await store.getById("executor-permissions"))!; +} + +async function expectUnchanged(row: AutomationRow) { + expect(await new AutomationStore(env.DB).getById(row.id)).toEqual(row); + const audit = await env.DB.prepare( + "SELECT action FROM authorization_audit_events WHERE action = 'automation.executor_changed'" + ).all(); + expect(audit.results).toEqual([]); +} + +async function expectDenied(response: Response, row: AutomationRow, code: string, reason: string) { + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ code, reason_code: reason }); + await expectUnchanged(row); +} + +describe("automation executor launch permissions (integration)", () => { + beforeEach(async () => { + await cleanD1Tables(); + for (const userId of [EXECUTOR, LEAD, CANDIDATE]) await seedActiveUser(userId); + await seedTeam(TEAM, [ + [EXECUTOR, "member"], + [LEAD, "lead"], + [CANDIDATE, "member"], + ]); + await customRole(LEAD, ["automations.manage.own"]); + }); + afterEach(cleanD1Tables); + + it.each([ + { target: "repoless", missingPermission: "sessions.create" }, + { target: "direct", missingPermission: "repositories.use" }, + { target: "environment", missingPermission: "environments.use" }, + { target: "mixed", missingPermission: "repositories.use" }, + { target: "mixed", missingPermission: "environments.use" }, + ] as const)( + "rejects $target reassignment without $missingPermission before mutation or audit", + async ({ target, missingPermission }) => { + const row = await saveAutomation(target); + await customRole(CANDIDATE, [ + ...LAUNCH_PERMISSIONS.filter((permission) => permission !== missingPermission), + "automations.read", + "automations.manage.any", + "automations.trigger.any", + ]); + await expectDenied( + await patch(CANDIDATE), + row, + "automation_executor_unauthorized", + "execution_authorization_denied" + ); + } + ); + + it.each(TARGETS)( + "accepts $target executors with exactly the required launch permissions", + async ({ target, permissions }) => { + const row = await saveAutomation(target); + await customRole(CANDIDATE, permissions); + const store = new AutomationStore(env.DB); + const repositories = await store.getRepositoriesForAutomation(row.id); + const environments = await store.getEnvironmentsForAutomation(row.id); + const response = await patch(CANDIDATE); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + automation: { + id: row.id, + ownerTeamId: TEAM, + userId: CANDIDATE, + capabilities: { canManage: true, canRead: false }, + }, + }); + expect(await store.getById(row.id)).toEqual({ + ...row, + user_id: CANDIDATE, + updated_at: expect.any(Number), + }); + expect(await store.getRepositoriesForAutomation(row.id)).toEqual(repositories); + expect(await store.getEnvironmentsForAutomation(row.id)).toEqual(environments); + } + ); + + it("revalidates same-ID executors before the no-op", async () => { + const row = await saveAutomation("mixed"); + await customRole(EXECUTOR, ["sessions.create", "environments.use"]); + await expectDenied( + await patch(EXECUTOR), + row, + "automation_executor_unauthorized", + "execution_authorization_denied" + ); + }); + + it.each(["missing", "suspended", "unassigned"])( + "preserves the %s canonical-user error ahead of execution or membership denial", + async (state) => { + const row = await saveAutomation("mixed"); + await customRole(CANDIDATE, []); + await env.DB.prepare("DELETE FROM team_memberships WHERE team_id = ? AND user_id = ?") + .bind(TEAM, CANDIDATE) + .run(); + if (state === "suspended") { + await env.DB.prepare("UPDATE users SET suspended_at = 2 WHERE id = ?") + .bind(CANDIDATE) + .run(); + } else if (state === "unassigned") { + await env.DB.prepare("DELETE FROM user_role_assignments WHERE user_id = ?") + .bind(CANDIDATE) + .run(); + } + const response = await patch( + state === "missing" ? "ffffffffffffffffffffffffffffffff" : CANDIDATE + ); + expect(response.status).toBe(state === "missing" ? 404 : 409); + await expect(response.json()).resolves.toMatchObject( + state === "missing" + ? { error: "User not found" } + : { code: "user_inactive", reason_code: "user_inactive" } + ); + await expectUnchanged(row); + } + ); + + it("preserves membership denial ahead of missing launch permissions", async () => { + const row = await saveAutomation("mixed"); + await customRole(CANDIDATE, []); + await env.DB.prepare("DELETE FROM team_memberships WHERE team_id = ? AND user_id = ?") + .bind(TEAM, CANDIDATE) + .run(); + await expectDenied(await patch(CANDIDATE), row, "automation_action_denied", "not_member"); + }); + + it("does not let manage.any bypass reassignment authority", async () => { + const row = await saveAutomation("repoless"); + await customRole(EXECUTOR, ["automations.manage.any"]); + await expectDenied( + await patch(CANDIDATE, EXECUTOR), + row, + "automation_action_denied", + "not_owner_or_lead" + ); + }); + + describe("guarded write after preflight", () => { + function revoke(userId: string, permission: PermissionId) { + return env.DB.prepare( + `DELETE FROM role_permissions WHERE permission_id = ? + AND role_id = (SELECT role_id FROM user_role_assignments WHERE user_id = ?)` + ) + .bind(permission, userId) + .run(); + } + + async function write(row: AutomationRow, actorUserId = LEAD) { + const [result] = await sqlDatabase(env.DB).batch([ + new AutomationStore(env.DB).bindExecutorChange(row, CANDIDATE, actorUserId), + ]); + return result.meta.changes; + } + + it("commits when caller authority and candidate launch permissions still hold", async () => { + const row = await saveAutomation("mixed"); + await customRole(CANDIDATE, LAUNCH_PERMISSIONS); + expect(await write(row)).toBe(1); + expect((await new AutomationStore(env.DB).getById(row.id))?.user_id).toBe(CANDIDATE); + }); + + it.each([ + [ + "the caller is demoted from lead", + () => + env.DB.prepare("UPDATE team_memberships SET role = 'member' WHERE user_id = ?") + .bind(LEAD) + .run(), + ], + ["the caller loses management permission", () => revoke(LEAD, "automations.manage.own")], + ["the candidate loses a target permission", () => revoke(CANDIDATE, "environments.use")], + ])("refuses the write when %s", async (_change, change) => { + const row = await saveAutomation("mixed"); + await customRole(CANDIDATE, LAUNCH_PERMISSIONS); + await change(); + expect(await write(row)).toBe(0); + expect(await new AutomationStore(env.DB).getById(row.id)).toEqual(row); + }); + + it("derives target requirements from the targets stored at write time", async () => { + const row = await saveAutomation("direct"); + await customRole(CANDIDATE, ["sessions.create", "repositories.use"]); + // A concurrent edit adds an environment the candidate cannot use. + await seedEnvironment(ENVIRONMENT, TEAM, [{ ...REPOSITORY, position: 0 }]); + await sqlDatabase(env.DB).batch( + new AutomationStore(env.DB).bindEnvironmentInserts(row.id, [ENVIRONMENT], 2) + ); + expect(await write(row)).toBe(0); + expect((await new AutomationStore(env.DB).getById(row.id))?.user_id).toBe(EXECUTOR); + }); + }); + + it("requires caller management permission before candidate checks", async () => { + const row = await saveAutomation("mixed"); + await customRole(CANDIDATE, []); + await env.DB.prepare( + "DELETE FROM role_permissions WHERE role_id = (SELECT role_id FROM user_role_assignments WHERE user_id = ?)" + ) + .bind(LEAD) + .run(); + await expectDenied( + await patch(CANDIDATE), + row, + "automation_action_denied", + "missing_permission" + ); + }); +}); diff --git a/packages/control-plane/test/integration/automation-run-session-privacy.test.ts b/packages/control-plane/test/integration/automation-run-session-privacy.test.ts new file mode 100644 index 0000000000..9ea48be174 --- /dev/null +++ b/packages/control-plane/test/integration/automation-run-session-privacy.test.ts @@ -0,0 +1,265 @@ +import { env } from "cloudflare:test"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { BUILT_IN_ROLE_REGISTRY } from "@open-inspect/shared/rbac"; +import type { + AutomationRun, + ListAutomationInvocationsResponse, +} from "@open-inspect/shared/types/automations"; +import { toAutomationRun } from "../../src/db/automation-store"; +import { SessionCollaboratorStore } from "../../src/db/session-collaborators"; +import { cleanD1Tables } from "./cleanup"; +import { seedActiveUser, serviceFetch, type ServiceRequestInit } from "./helpers"; +import { makeRunRow, seedRun } from "./run-helpers"; +import { assignCustomRole, seedTeam } from "./ownership-test-helpers"; + +const SESSION_OWNER = "11111111111111111111111111111111"; +const MEMBER = "22222222222222222222222222222222"; +const ADMIN = "44444444444444444444444444444444"; +const WORKSPACE_OWNER = "55555555555555555555555555555555"; +const COLLABORATOR = "66666666666666666666666666666666"; +const AUTOMATION_TEAM = "team_automation_privacy"; +const SESSION_TEAM = "team_session_privacy"; +const AUTOMATION_ID = "auto-session-privacy"; +const INVOCATION_ID = "inv-session-privacy"; +const PRIVATE_SESSION = "private-linked-session"; +const VISIBLE_SESSION = "visible-linked-session"; +const privateRow = makeRunRow(AUTOMATION_ID, { + id: "run-private", + invocation_id: INVOCATION_ID, + session_id: PRIVATE_SESSION, + status: "completed", + scheduled_at: 1000, + started_at: 1100, + completed_at: 2000, + created_at: 1000, + repo_owner: "group/subgroup", + repo_name: "private-target", + repo_id: 41, + base_branch: "release", + environment_id: "env_private_run_snapshot", +}); +const visibleRow = { + ...privateRow, + id: "run-visible", + session_id: VISIBLE_SESSION, + repo_name: "visible-target", + repo_id: 42, + base_branch: "main", + environment_id: "env_visible_run_snapshot", + completed_at: 2100, + created_at: 1001, +}; +const privateRun = toAutomationRun({ + ...privateRow, + session_title: "Confidential linked session", + artifact_summary: null, +}); +const visibleRun = toAutomationRun({ + ...visibleRow, + session_title: "Visible linked session", + artifact_summary: null, +}); +function request(suffix: string, init: ServiceRequestInit) { + return serviceFetch(`https://cp.test/automations/${AUTOMATION_ID}${suffix}`, init); +} + +async function breakGlassAudits() { + return ( + await env.DB.prepare( + `SELECT principal_kind, actor_user_id_snapshot, resource_type, resource_id, + team_id, reason_code, operation_result, request_id + FROM authorization_audit_events WHERE action = 'session.private_break_glass'` + ).all() + ).results; +} + +async function expectHistory( + init: ServiceRequestInit, + { privateReadable = false, visibleReadable = true } = {} +) { + const runs = [privateRun, visibleRun].map((run, index) => + (index === 0 ? privateReadable : visibleReadable) + ? run + : { ...run, sessionId: null, sessionTitle: null, artifactSummary: null } + ); + const listed = await request("/invocations", init); + expect(listed.status).toBe(200); + expect(await listed.json()).toMatchObject({ + invocations: [{ id: INVOCATION_ID, runs }], + total: 1, + }); + for (const run of runs) { + const item = await request(`/runs/${run.id}`, init); + expect(item.status).toBe(200); + expect(await item.json<{ run: AutomationRun }>()).toEqual({ run }); + } +} + +describe("automation run linked session privacy (real D1)", () => { + beforeEach(async () => { + await cleanD1Tables(); + for (const userId of [SESSION_OWNER, MEMBER, ADMIN, WORKSPACE_OWNER, COLLABORATOR]) + await seedActiveUser(userId); + await env.DB.batch([ + ...[ADMIN, WORKSPACE_OWNER].map((userId) => + env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?").bind( + BUILT_IN_ROLE_REGISTRY[userId === ADMIN ? "administrator" : "owner"].id, + userId + ) + ), + ]); + for (const teamId of [AUTOMATION_TEAM, SESSION_TEAM]) { + await seedTeam(teamId, [ + [SESSION_OWNER, "member"], + [MEMBER, "member"], + [ADMIN, "member"], + [COLLABORATOR, "member"], + ]); + } + await env.DB.prepare( + `INSERT INTO automations (id, owner_team_id, name, instructions, model, created_by, user_id, created_at, updated_at) + VALUES (?, ?, 'Session privacy', 'Fixture only; never execute', 'anthropic/claude-sonnet-4-6', ?, ?, 1000, 1000)` + ) + .bind(AUTOMATION_ID, AUTOMATION_TEAM, SESSION_OWNER, SESSION_OWNER) + .run(); + for (const [row, run] of [ + [privateRow, privateRun], + [visibleRow, visibleRun], + ] as const) { + await env.DB.prepare( + `INSERT INTO sessions + (id, title, user_id, owner_team_id, visibility, repo_owner, repo_name, base_branch, + status, automation_id, automation_run_id, spawn_source, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'different-session-repo', 'not-the-run-snapshot', 'different-session-branch', + 'completed', ?, ?, 'automation', ?, ?)` + ) + .bind( + row.session_id, + run.sessionTitle, + SESSION_OWNER, + row === privateRow ? SESSION_TEAM : null, + row === privateRow ? "private" : "workspace", + AUTOMATION_ID, + row.id, + row.created_at, + row.completed_at + ) + .run(); + await seedRun(row); + } + }); + afterEach(cleanD1Tables); + + it("redacts private links for ordinary administrators", async () => { + await expectHistory({ as: { userId: ADMIN, role: "administrator" } }); + expect(await breakGlassAudits()).toEqual([]); + }); + + it("redacts private metadata for an actorless Slack bot", async () => { + await expectHistory({ service: "slack-bot" }); + expect(await breakGlassAudits()).toEqual([]); + }); + + it("requires sessions.read even for the session owner with automations.read", async () => { + await assignCustomRole(SESSION_OWNER, ["automations.read"]); + await expectHistory( + { as: { userId: SESSION_OWNER, role: "member" } }, + { visibleReadable: false } + ); + expect(await breakGlassAudits()).toEqual([]); + }); + + it("preserves private metadata for the session owner without break-glass", async () => { + await expectHistory( + { as: { userId: SESSION_OWNER, role: "member" } }, + { privateReadable: true } + ); + expect(await breakGlassAudits()).toEqual([]); + }); + + it("rechecks collaborator disclosure after membership removal", async () => { + const collaborators = new SessionCollaboratorStore(env.DB); + await collaborators.add(PRIVATE_SESSION, COLLABORATOR, SESSION_OWNER); + const init: ServiceRequestInit = { as: { userId: COLLABORATOR, role: "member" } }; + await expectHistory(init, { privateReadable: true }); + await env.DB.prepare("DELETE FROM team_memberships WHERE team_id = ? AND user_id = ?") + .bind(SESSION_TEAM, COLLABORATOR) + .run(); + expect(await collaborators.listUserIds(PRIVATE_SESSION)).toEqual([COLLABORATOR]); + await expectHistory(init); + expect(await breakGlassAudits()).toEqual([]); + }); + + it("nulls a dangling session link after the persisted session row is removed", async () => { + await env.DB.prepare("DELETE FROM sessions WHERE id = ?").bind(PRIVATE_SESSION).run(); + expect( + await env.DB.prepare("SELECT session_id FROM automation_runs WHERE id = ?") + .bind(privateRun.id) + .first() + ).toEqual({ session_id: PRIVATE_SESSION }); + await expectHistory({ as: { userId: SESSION_OWNER, role: "member" } }); + expect(await breakGlassAudits()).toEqual([]); + }); + + it("uses the linked session's current team membership for run disclosure", async () => { + await env.DB.prepare("UPDATE sessions SET owner_team_id = ?, visibility = 'team' WHERE id = ?") + .bind(SESSION_TEAM, VISIBLE_SESSION) + .run(); + const init: ServiceRequestInit = { as: { userId: MEMBER, role: "member" } }; + await expectHistory(init); + await env.DB.prepare("DELETE FROM team_memberships WHERE team_id = ? AND user_id = ?") + .bind(SESSION_TEAM, MEMBER) + .run(); + await expectHistory(init, { visibleReadable: false }); + expect(await breakGlassAudits()).toEqual([]); + }); + + it("preserves a workspace Owner's own private metadata without break-glass", async () => { + await env.DB.prepare("UPDATE sessions SET user_id = ? WHERE id = ?") + .bind(WORKSPACE_OWNER, PRIVATE_SESSION) + .run(); + await expectHistory( + { as: { userId: WORKSPACE_OWNER, role: "owner" } }, + { privateReadable: true } + ); + expect(await breakGlassAudits()).toEqual([]); + }); + + it("redacts Owner lists but audits each private item disclosure", async () => { + const init: ServiceRequestInit = { as: { userId: WORKSPACE_OWNER, role: "owner" } }; + const listed = await request("/invocations", init); + expect(listed.status).toBe(200); + expect(await listed.json()).toMatchObject({ + invocations: [ + { + id: INVOCATION_ID, + runs: [ + { ...privateRun, sessionId: null, sessionTitle: null, artifactSummary: null }, + visibleRun, + ], + }, + ], + total: 1, + }); + expect(await breakGlassAudits()).toEqual([]); + for (let read = 0; read < 2; read++) { + const item = await request(`/runs/${privateRun.id}`, init); + expect(item.status).toBe(200); + expect(await item.json<{ run: AutomationRun }>()).toEqual({ run: privateRun }); + const audits = await breakGlassAudits(); + expect(audits).toHaveLength(read + 1); + for (const audit of audits) + expect(audit).toEqual({ + principal_kind: "user", + actor_user_id_snapshot: WORKSPACE_OWNER, + resource_type: "session", + resource_id: PRIVATE_SESSION, + team_id: SESSION_TEAM, + reason_code: "session.private_break_glass", + operation_result: "applied", + request_id: expect.any(String), + }); + expect(new Set(audits.map((audit) => audit.request_id)).size).toBe(read + 1); + } + }); +}); diff --git a/packages/control-plane/test/integration/automation-team-execution.test.ts b/packages/control-plane/test/integration/automation-team-execution.test.ts new file mode 100644 index 0000000000..17a306be06 --- /dev/null +++ b/packages/control-plane/test/integration/automation-team-execution.test.ts @@ -0,0 +1,224 @@ +import { env } from "cloudflare:test"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { SessionVisibility } from "@open-inspect/shared/types/teams"; +import { isAutomationExecutionAuthorized } from "../../src/automation/authorization-guard"; +import { createCloudflareEnv } from "../../src/cloudflare/platform"; +import { AutomationStore } from "../../src/db/automation-store"; +import { SessionIndexStore } from "../../src/db/session-index"; +import { Scheduler } from "../../src/scheduler/scheduler"; +import { cleanD1Tables } from "./cleanup"; +import { queryDO, seedActiveUser, serviceFetch, sqlDatabase } from "./helpers"; +import { fetchRuns } from "./run-helpers"; +import { seedTeam } from "./ownership-test-helpers"; + +const EXECUTOR = "11111111111111111111111111111111"; +const LEAD = "22222222222222222222222222222222"; +const MEMBER = "33333333333333333333333333333333"; +const ADMIN = "44444444444444444444444444444444"; +const TEAM = "team_automation_execution"; +const OTHER_TEAM = "team_automation_execution_other"; + +async function saveAutomation(id: string, ownerTeamId: string | null = TEAM) { + await env.DB.prepare( + `INSERT INTO automations + (id, owner_team_id, name, instructions, schedule_cron, model, next_run_at, + consecutive_failures, created_by, user_id, created_at, updated_at) + VALUES (?, ?, ?, 'Run tests', '0 9 * * *', 'anthropic/claude-sonnet-4-6', ?, 2, ?, ?, 1, 1)` + ) + .bind(id, ownerTeamId, id, Date.now() - 60_000, EXECUTOR, EXECUTOR) + .run(); + return (await new AutomationStore(env.DB).getById(id))!; +} + +function authorized(automationId: string, executionUserId?: string) { + return isAutomationExecutionAuthorized(sqlDatabase(env.DB), { + automationId, + executionUserId, + requiresRepositoryUse: false, + requiresEnvironmentUse: false, + }); +} + +function createScheduler() { + return new Scheduler(sqlDatabase(env.DB), createCloudflareEnv(env), { submit() {} }); +} + +async function expectNoLaunch(automationId: string) { + expect(await fetchRuns(automationId)).toEqual([]); + const sessions = await env.DB.prepare("SELECT id FROM sessions WHERE automation_id = ?") + .bind(automationId) + .all(); + expect(sessions.results).toEqual([]); +} + +async function expectLaunchedSession( + automationId: string, + ownerTeamId: string | null, + visibility: SessionVisibility, + userId: string +) { + const runs = await fetchRuns(automationId); + expect(runs).toEqual([ + expect.objectContaining({ + status: "running", + session_id: expect.any(String), + }), + ]); + const run = runs[0]; + expect(await new SessionIndexStore(env.DB).get(run.session_id!)).toMatchObject({ + ownerTeamId, + visibility, + userId, + spawnSource: "automation", + automationId, + automationRunId: run.id, + repoOwner: null, + repoName: null, + baseBranch: null, + }); + const stub = env.SESSION.get(env.SESSION.idFromName(run.session_id!)); + expect(await queryDO(stub, "SELECT repo_owner, repo_name, base_branch FROM session")).toEqual([ + { repo_owner: null, repo_name: null, base_branch: null }, + ]); + expect( + await queryDO( + stub, + "SELECT m.content, m.source, p.canonical_user_id FROM messages m JOIN participants p ON p.id = m.author_id" + ) + ).toEqual([{ content: "Run tests", source: "automation", canonical_user_id: userId }]); +} + +describe("automation team execution (integration)", () => { + beforeEach(async () => { + await cleanD1Tables(); + for (const userId of [EXECUTOR, LEAD, MEMBER, ADMIN]) await seedActiveUser(userId); + await env.DB.prepare( + "UPDATE user_role_assignments SET role_id = 'role_builtin_administrator' WHERE user_id = ?" + ) + .bind(ADMIN) + .run(); + await seedTeam(TEAM, [ + [EXECUTOR, "member"], + [LEAD, "lead"], + [MEMBER, "member"], + ]); + await seedTeam(OTHER_TEAM, [ + [EXECUTOR, "member"], + [ADMIN, "member"], + ]); + }); + afterEach(cleanD1Tables); + + it("rejects a departed executor despite other-team membership", async () => { + const row = await saveAutomation("auto-departed-executor"); + expect(await authorized(row.id)).toBe(true); + await env.DB.prepare("DELETE FROM team_memberships WHERE team_id = ? AND user_id = ?") + .bind(TEAM, EXECUTOR) + .run(); + expect(await authorized(row.id)).toBe(false); + expect(await createScheduler().tick()).toEqual({ processed: 0, skipped: 1, failed: 0 }); + await expectNoLaunch(row.id); + }); + + it("rejects nonmember administrator manual execution", async () => { + const row = await saveAutomation("auto-nonmember-requester"); + expect(await authorized(row.id)).toBe(true); + await expect(createScheduler().trigger(row.id, ADMIN)).rejects.toMatchObject({ + name: "AutomationExecutionUnauthorizedError", + reason: "execution_authorization_denied", + }); + expect( + (await new AutomationStore(env.DB).listInvocations(row.id, { limit: 10, offset: 0 })) + .invocations + ).toEqual([]); + await expectNoLaunch(row.id); + }); + + it("rejects archived manual execution and pauses scheduled work without a failure strike", async () => { + const row = await saveAutomation("auto-archived-team"); + await env.DB.prepare("UPDATE teams SET archived_at = 2 WHERE id = ?").bind(TEAM).run(); + expect(await authorized(row.id)).toBe(false); + expect(await authorized(row.id, MEMBER)).toBe(false); + const scheduler = createScheduler(); + await expect(scheduler.trigger(row.id, MEMBER)).rejects.toMatchObject({ + reason: "team_archived", + }); + const store = new AutomationStore(env.DB); + expect((await store.listInvocations(row.id, { limit: 10, offset: 0 })).invocations).toEqual([]); + expect(await scheduler.tick()).toEqual({ processed: 0, skipped: 1, failed: 0 }); + const { invocations } = await store.listInvocations(row.id, { limit: 10, offset: 0 }); + expect(invocations).toEqual([ + expect.objectContaining({ + source: "schedule", + scheduledAt: row.next_run_at, + status: "skipped", + skipReason: "team_archived", + runs: [], + }), + ]); + expect(await store.getInvocationById(invocations[0].id)).toMatchObject({ + failure_counted_at: null, + }); + expect(await store.getById(row.id)).toMatchObject({ + enabled: 0, + next_run_at: null, + consecutive_failures: 2, + }); + await expectNoLaunch(row.id); + expect(await scheduler.tick()).toEqual({ processed: 0, skipped: 0, failed: 0 }); + expect( + (await store.listInvocations(row.id, { limit: 10, offset: 0 })).invocations + ).toHaveLength(1); + }); + + it("launches a scheduled run after lead executor reassignment", async () => { + const row = await saveAutomation("auto-reassigned-executor"); + await env.DB.prepare("DELETE FROM team_memberships WHERE team_id = ? AND user_id = ?") + .bind(TEAM, EXECUTOR) + .run(); + expect(await authorized(row.id)).toBe(false); + const response = await serviceFetch(`https://cp.test/automations/${row.id}`, { + as: { userId: LEAD, role: "member" }, + method: "PATCH", + body: JSON.stringify({ userId: MEMBER }), + }); + expect(response.status).toBe(200); + expect(await new AutomationStore(env.DB).getById(row.id)).toMatchObject({ + created_by: EXECUTOR, + user_id: MEMBER, + owner_team_id: TEAM, + }); + expect(await authorized(row.id)).toBe(true); + expect(await createScheduler().tick()).toEqual({ processed: 1, skipped: 0, failed: 0 }); + await expectLaunchedSession(row.id, TEAM, "team", MEMBER); + }); + + it.each(["team", "workspace", "private"] as const)( + "creates a manual session with the team's %s default", + async (visibility) => { + await env.DB.prepare("UPDATE teams SET default_visibility = ? WHERE id = ?") + .bind(visibility, TEAM) + .run(); + const row = await saveAutomation(`auto-session-${visibility}`); + if (visibility === "private") { + await env.DB.prepare("DELETE FROM team_memberships WHERE team_id = ? AND user_id = ?") + .bind(TEAM, EXECUTOR) + .run(); + expect(await authorized(row.id)).toBe(false); + } + await createScheduler().trigger(row.id, MEMBER); + await expectLaunchedSession(row.id, TEAM, visibility, MEMBER); + } + ); + + it("keeps workspace sessions workspace-owned despite archived memberships", async () => { + await env.DB.prepare( + "UPDATE teams SET default_visibility = 'private', archived_at = 2 WHERE id = ?" + ) + .bind(TEAM) + .run(); + const row = await saveAutomation("auto-workspace-session", null); + await createScheduler().trigger(row.id, MEMBER); + await expectLaunchedSession(row.id, null, "workspace", MEMBER); + }); +}); diff --git a/packages/control-plane/test/integration/automation-team-grants.test.ts b/packages/control-plane/test/integration/automation-team-grants.test.ts new file mode 100644 index 0000000000..0f0f43b660 --- /dev/null +++ b/packages/control-plane/test/integration/automation-team-grants.test.ts @@ -0,0 +1,350 @@ +import { env } from "cloudflare:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AutomationInvocationSource } from "@open-inspect/shared/types/automations"; +import * as automationRepositories from "../../src/automation/repository"; +import { createCloudflareEnv } from "../../src/cloudflare/platform"; +import { AutomationStore, type AutomationRepositoryInsert } from "../../src/db/automation-store"; +import { EnvironmentStore } from "../../src/db/environments"; +import { SessionIndexStore } from "../../src/db/session-index"; +import { TeamRepositoryGrantStore } from "../../src/db/team-repository-grants"; +import * as repositoryResolution from "../../src/repos/resolve"; +import { AutomationExecutionUnauthorizedError, Scheduler } from "../../src/scheduler/scheduler"; +import * as sessionInitialization from "../../src/session/initialize"; +import { cleanD1Tables } from "./cleanup"; +import { seedActiveUser, sqlDatabase } from "./helpers"; +import { fetchRuns } from "./run-helpers"; +import { seedEnvironment, seedGrant, seedTeam } from "./ownership-test-helpers"; + +const EXECUTOR = "11111111111111111111111111111111"; +const REQUESTER = "22222222222222222222222222222222"; +const TEAM = "team_automation_grants"; +const OTHER_TEAM = "team_automation_grants_other"; +const ENVIRONMENT = "env_55555555555555555555555555555555"; +const WEB = { + position: 0, + repo_owner: "acme/group", + repo_name: "web", + repo_id: 101, + base_branch: "main", +}; +const API = { ...WEB, position: 1, repo_name: "api", repo_id: 202, base_branch: "develop" }; +const insertInvocationGuarded = AutomationStore.prototype.insertInvocationGuarded; + +async function saveAutomation( + source: AutomationInvocationSource, + repositories: AutomationRepositoryInsert[] = [WEB], + environmentIds: string[] = [] +) { + const id = `auto-team-grants-${source}`; + await env.DB.prepare( + `INSERT INTO automations + (id, owner_team_id, name, instructions, trigger_type, schedule_cron, model, + next_run_at, consecutive_failures, created_by, user_id, created_at, updated_at) + VALUES (?, ?, 'Current team grants', 'Run tests', ?, ?, 'anthropic/claude-sonnet-4-6', ?, 2, ?, ?, 1, 1)` + ) + .bind( + id, + TEAM, + source === "event" ? "webhook" : "schedule", + source === "event" ? null : "0 9 * * *", + source === "schedule" ? Date.now() - 60_000 : null, + EXECUTOR, + EXECUTOR + ) + .run(); + const store = new AutomationStore(env.DB); + await sqlDatabase(env.DB).batch([ + ...store.bindRepositoryInserts(id, repositories, 1), + ...store.bindEnvironmentInserts(id, environmentIds, 1), + ]); + return (await store.getById(id))!; +} + +async function expectDenied(source: AutomationInvocationSource) { + const store = new AutomationStore(env.DB); + const row = (await store.getById(`auto-team-grants-${source}`))!; + const scheduler = new Scheduler(sqlDatabase(env.DB), createCloudflareEnv(env), { submit() {} }); + if (source === "manual") { + await expect(scheduler.trigger(row.id, REQUESTER)).rejects.toMatchObject({ + name: AutomationExecutionUnauthorizedError.name, + reason: "target_team_missing_grant", + }); + } else if (source === "schedule") { + expect(await scheduler.tick()).toEqual({ processed: 0, skipped: 1, failed: 0 }); + } else { + expect( + await scheduler.event({ + source: "webhook", + automationId: row.id, + eventType: "webhook.received", + triggerKey: `webhook:${row.id}:delivery-1`, + concurrencyKey: `webhook:${row.id}`, + contextBlock: "Webhook received", + meta: {}, + body: {}, + }) + ).toEqual({ triggered: 0, skipped: 1, steered: 0 }); + } + expect(sessionInitialization.initializeSession).not.toHaveBeenCalled(); + expect(AutomationStore.prototype.insertInvocationGuarded).not.toHaveBeenCalled(); + expect(await fetchRuns(row.id)).toEqual([]); + const sessions = await env.DB.prepare("SELECT id FROM sessions WHERE automation_id = ?") + .bind(row.id) + .all(); + expect(sessions.results).toEqual([]); + const { invocations } = await store.listInvocations(row.id, { limit: 10, offset: 0 }); + expect(invocations).toEqual( + source === "schedule" + ? [ + expect.objectContaining({ + source, + scheduledAt: row.next_run_at, + skipReason: "target_team_missing_grant", + status: "skipped", + runs: [], + }), + ] + : [] + ); + if (source === "schedule") { + expect(await store.getInvocationById(invocations[0].id)).toMatchObject({ + failure_counted_at: null, + }); + expect(await scheduler.tick()).toEqual({ processed: 0, skipped: 0, failed: 0 }); + expect( + (await store.listInvocations(row.id, { limit: 10, offset: 0 })).invocations + ).toHaveLength(1); + } + expect(await store.getById(row.id)).toMatchObject({ + enabled: source === "schedule" ? 0 : 1, + next_run_at: null, + consecutive_failures: 2, + }); +} + +describe("automation current team repository grants (integration)", () => { + beforeEach(async () => { + await cleanD1Tables(); + for (const userId of [EXECUTOR, REQUESTER]) await seedActiveUser(userId); + await seedTeam(TEAM, [ + [EXECUTOR, "member"], + [REQUESTER, "member"], + ]); + await seedTeam(OTHER_TEAM); + vi.spyOn(automationRepositories, "resolveAutomationRepositories").mockImplementation( + async (_env, repositories) => + repositories.map((requested) => ({ + requested, + error: null, + repository: { + repoOwner: requested.repo_owner, + repoName: requested.repo_name, + repoId: requested.repo_name === "web" ? 101 : 202, + baseBranch: requested.base_branch ?? "main", + }, + })) + ); + vi.spyOn(repositoryResolution, "resolveSessionRepositories").mockImplementation( + async (_env, repositories) => + repositories.map((repository) => ({ + ...repository, + repoId: repository.repoName === "web" ? 101 : repository.repoName === "api" ? 202 : 303, + baseBranch: repository.baseBranch ?? "main", + })) + ); + vi.spyOn(TeamRepositoryGrantStore.prototype, "covers"); + vi.spyOn(AutomationStore.prototype, "insertInvocationGuarded"); + vi.spyOn(sessionInitialization, "initializeSession"); + }); + afterEach(async () => { + vi.restoreAllMocks(); + await cleanD1Tables(); + }); + + it.each(["manual", "schedule", "event"] as const)( + "rejects revoked direct grants on %s firing", + async (source) => { + await seedGrant(TEAM, WEB); + await saveAutomation(source); + expect(await new TeamRepositoryGrantStore(env.DB).covers(TEAM, [101])).toBe(true); + await env.DB.prepare("DELETE FROM team_repository_grants WHERE team_id = ?").bind(TEAM).run(); + await expectDenied(source); + expect(TeamRepositoryGrantStore.prototype.covers).toHaveBeenLastCalledWith(TEAM, [101]); + } + ); + + it.each(["manual", "schedule"] as const)( + "refuses %s admission when grants are revoked after the coverage check", + async (source) => { + await seedGrant(TEAM, WEB); + const row = await saveAutomation(source); + const covers = TeamRepositoryGrantStore.prototype.covers; + vi.mocked(TeamRepositoryGrantStore.prototype.covers).mockImplementationOnce(async function ( + this: TeamRepositoryGrantStore, + teamId, + repoIds + ) { + const covered = await covers.call(this, teamId, repoIds); + // A concurrent revocation lands between coverage and the guarded insert. + await env.DB.prepare("DELETE FROM team_repository_grants WHERE team_id = ?") + .bind(TEAM) + .run(); + await env.DB.prepare("UPDATE teams SET grants_version = grants_version + 1 WHERE id = ?") + .bind(TEAM) + .run(); + return covered; + }); + const scheduler = new Scheduler(sqlDatabase(env.DB), createCloudflareEnv(env), { + submit() {}, + }); + if (source === "manual") { + await expect(scheduler.trigger(row.id, REQUESTER)).rejects.toMatchObject({ + name: "AutomationTriggerBlockedError", + reason: "team_grants_changed", + }); + } else { + expect(await scheduler.tick()).toEqual({ processed: 0, skipped: 1, failed: 0 }); + } + const store = new AutomationStore(env.DB); + expect(AutomationStore.prototype.insertInvocationGuarded).toHaveBeenCalledOnce(); + expect(await fetchRuns(row.id)).toEqual([]); + expect((await store.listInvocations(row.id, { limit: 10, offset: 0 })).invocations).toEqual( + [] + ); + expect(sessionInitialization.initializeSession).not.toHaveBeenCalled(); + // The slot stays due, so the next tick re-authorizes against current grants. + expect((await store.getById(row.id))?.next_run_at).toBe(row.next_run_at); + } + ); + + it("rechecks all environment grants against current resolved member IDs", async () => { + await seedGrant(TEAM, WEB); + await seedGrant(TEAM, API); + await seedEnvironment(ENVIRONMENT, TEAM, [WEB, API]); + await saveAutomation("manual", [], [ENVIRONMENT]); + expect(await new TeamRepositoryGrantStore(env.DB).covers(TEAM, [101, 202])).toBe(true); + await env.DB.prepare( + "DELETE FROM team_repository_grants WHERE team_id = ? AND repo_external_id = ?" + ) + .bind(TEAM, 202) + .run(); + await expectDenied("manual"); + expect(TeamRepositoryGrantStore.prototype.covers).toHaveBeenLastCalledWith(TEAM, [101, 202]); + await seedGrant(TEAM, API); + const resolve = vi.mocked(repositoryResolution.resolveSessionRepositories); + const members = await resolve.mock.results[0].value; + resolve.mockResolvedValueOnce([members[0], { ...members[1], repoId: 909 }]); + await expectDenied("manual"); + expect(resolve).toHaveBeenCalledTimes(2); + expect(TeamRepositoryGrantStore.prototype.covers).toHaveBeenLastCalledWith(TEAM, [101, 909]); + }); + + it("rechecks installation grants within the owning team", async () => { + const store = new TeamRepositoryGrantStore(env.DB); + await seedGrant(TEAM, "installation"); + expect(await store.covers(TEAM, [101, 202])).toBe(true); + expect(await store.covers(OTHER_TEAM, [101, 202])).toBe(false); + await env.DB.prepare("DELETE FROM team_repository_grants WHERE team_id = ?").bind(TEAM).run(); + expect(await store.covers(TEAM, [101, 202])).toBe(false); + }); + + it("checks the current resolved direct repository ID", async () => { + await seedGrant(TEAM, WEB); + const row = await saveAutomation("manual"); + vi.mocked(automationRepositories.resolveAutomationRepositories).mockResolvedValueOnce([ + { + requested: (await new AutomationStore(env.DB).getRepositoriesForAutomation(row.id))[0], + repository: { + repoOwner: WEB.repo_owner, + repoName: WEB.repo_name, + repoId: 909, + baseBranch: WEB.base_branch, + }, + error: null, + }, + ]); + await expectDenied("manual"); + expect(TeamRepositoryGrantStore.prototype.covers).toHaveBeenCalledWith(TEAM, [909]); + expect( + (await new AutomationStore(env.DB).getRepositoriesForAutomation(row.id))[0].repo_id + ).toBe(101); + }); + + it("freezes authorized members through successful session initialization", async () => { + await seedGrant(TEAM, WEB); + await seedGrant(TEAM, API); + await seedEnvironment(ENVIRONMENT, TEAM, [WEB, API]); + const row = await saveAutomation("schedule", [], [ENVIRONMENT]); + const editedMembers = [ + WEB, + { ...API, base_branch: "edited-after-admission" }, + { ...API, position: 2, repo_name: "ungranted", repo_id: 303 }, + ]; + vi.mocked(AutomationStore.prototype.insertInvocationGuarded).mockImplementation(async function ( + this: AutomationStore, + params + ) { + const result = await insertInvocationGuarded.call(this, params); + expect(result.inserted).toBe(true); + await new EnvironmentStore(env.DB).replaceRepositories(ENVIRONMENT, editedMembers); + return result; + }); + const runtime = vi.fn(async (request: Request) => { + const path = new URL(request.url).pathname; + expect(["/internal/init", "/internal/prompt"]).toContain(path); + return Response.json( + path === "/internal/init" + ? { status: "ok" } + : { messageId: "msg-snapshot", status: "queued" } + ); + }); + const schedulerEnv = createCloudflareEnv(env); + schedulerEnv.SESSION = (_sessionId, request) => runtime(request); + expect(await new Scheduler(sqlDatabase(env.DB), schedulerEnv, { submit() {} }).tick()).toEqual({ + processed: 1, + skipped: 0, + failed: 0, + }); + const resolve = vi.mocked(repositoryResolution.resolveSessionRepositories); + expect(resolve).toHaveBeenCalledTimes(1); + const authorizedMembers = await resolve.mock.results[0].value; + const init = runtime.mock.calls.find( + ([request]) => new URL(request.url).pathname === "/internal/init" + )!; + expect(await init[0].json()).toMatchObject({ + environmentId: ENVIRONMENT, + repoOwner: WEB.repo_owner, + repoName: WEB.repo_name, + repoId: 101, + defaultBranch: WEB.base_branch, + repositories: authorizedMembers, + }); + const runs = await fetchRuns(row.id); + expect(runs).toEqual([ + expect.objectContaining({ status: "running", session_id: expect.any(String) }), + ]); + const sessionId = runs[0].session_id!; + expect(await new SessionIndexStore(env.DB).get(sessionId)).toMatchObject({ + ownerTeamId: TEAM, + visibility: "team", + environmentId: ENVIRONMENT, + userId: EXECUTOR, + }); + const persisted = await env.DB.prepare( + "SELECT repo_owner AS repoOwner, repo_name AS repoName, repo_id AS repoId, base_branch AS baseBranch FROM session_repositories WHERE session_id = ? ORDER BY position" + ) + .bind(sessionId) + .all(); + expect(persisted.results).toEqual(authorizedMembers); + expect(sessionInitialization.initializeSession).toHaveBeenCalledTimes(1); + expect(TeamRepositoryGrantStore.prototype.covers).toHaveBeenCalledWith(TEAM, [101, 202]); + const insert = vi.mocked(AutomationStore.prototype.insertInvocationGuarded); + expect(resolve.mock.invocationCallOrder[0]).toBeLessThan(insert.mock.invocationCallOrder[0]); + expect( + vi.mocked(TeamRepositoryGrantStore.prototype.covers).mock.invocationCallOrder[0] + ).toBeLessThan(insert.mock.invocationCallOrder[0]); + expect(await new EnvironmentStore(env.DB).getRepositoriesForEnvironment(ENVIRONMENT)).toEqual( + editedMembers.map((member) => ({ environment_id: ENVIRONMENT, ...member })) + ); + }); +}); diff --git a/packages/control-plane/test/integration/automation-team-ownership.test.ts b/packages/control-plane/test/integration/automation-team-ownership.test.ts new file mode 100644 index 0000000000..553b769a48 --- /dev/null +++ b/packages/control-plane/test/integration/automation-team-ownership.test.ts @@ -0,0 +1,492 @@ +import { env } from "cloudflare:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AutomationStore } from "../../src/db/automation-store"; +import { TeamSettingsStore } from "../../src/db/team-settings"; +import * as routeShared from "../../src/routes/shared"; +import { cleanD1Tables } from "./cleanup"; +import { serviceFetch, sqlDatabase } from "./helpers"; +import { + assignCustomRole, + expectStatus, + ownershipRequest, + seedEnvironment, + seedGrant, + seedTeam, +} from "./ownership-test-helpers"; + +const EXECUTOR = "11111111111111111111111111111111"; +const LEAD = "22222222222222222222222222222222"; +const MEMBER = "33333333333333333333333333333333"; +const ADMIN = "44444444444444444444444444444444"; +const TEAM_A = "team_automation_a"; +const TEAM_B = "team_automation_b"; +const createBody = { + name: "Team automation", + instructions: "Run tests", + scheduleCron: "0 9 * * *", + scheduleTz: "UTC", +}; +const invisibleRoutes: Array<{ method: string; suffix: string; body?: unknown }> = [ + { method: "GET", suffix: "" }, + { method: "GET", suffix: "/invocations" }, + { method: "GET", suffix: "/runs/missing" }, + { method: "PUT", suffix: "", body: { name: "Must remain hidden" } }, + { method: "DELETE", suffix: "" }, + { method: "POST", suffix: "/pause" }, + { method: "POST", suffix: "/resume" }, + { method: "POST", suffix: "/trigger" }, + { method: "POST", suffix: "/regenerate-key" }, + { method: "PATCH", suffix: "", body: { userId: MEMBER } }, +]; + +function request(path: string, userId = EXECUTOR, method = "GET", body?: unknown) { + return ownershipRequest(path, { + as: { userId, role: userId === ADMIN ? "administrator" : "member" }, + method, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} + +async function automation(id: string, ownerTeamId: string | null, createdAt = 1) { + await env.DB.prepare( + `INSERT INTO automations + (id, name, instructions, model, schedule_cron, created_by, user_id, owner_team_id, created_at, updated_at) + VALUES (?, ?, 'Run tests', 'anthropic/claude-sonnet-4-6', '0 9 * * *', ?, ?, ?, ?, ?)` + ) + .bind(id, id, EXECUTOR, EXECUTOR, ownerTeamId, createdAt, createdAt) + .run(); +} + +const store = new AutomationStore(env.DB); +const repository = (id: number | null, name = "api") => ({ + position: 0, + repo_owner: "group/subgroup", + repo_name: name, + repo_id: id, + base_branch: "main", +}); + +describe("automation team ownership", () => { + beforeEach(async () => { + await cleanD1Tables(); + for (const userId of [EXECUTOR, LEAD, MEMBER, ADMIN]) { + expect((await request("/me/authorization", userId)).status).toBe(200); + } + await seedTeam(TEAM_A, [ + [EXECUTOR, "member"], + [LEAD, "lead"], + [MEMBER, "member"], + ]); + await seedTeam(TEAM_B, [[EXECUTOR, "member"]]); + }); + afterEach(async () => { + vi.restoreAllMocks(); + await cleanD1Tables(); + }); + + it.each([undefined, null])("enforces workspace/team defaults (%s)", async (teamId) => { + const created = await request("/automations", EXECUTOR, "POST", { ...createBody, teamId }); + expect(created.status).toBe(201); + const { automation: row } = await created.json<{ automation: { id: string } }>(); + const fetched = await request(`/automations/${row.id}`); + expect(fetched.status).toBe(200); + await expect(fetched.json()).resolves.toMatchObject({ + automation: { + ownerTeamId: null, + userId: EXECUTOR, + capabilities: { canRead: true, canManage: true, canTrigger: true }, + }, + }); + await new TeamSettingsStore(env.DB).set({ requireTeamOnCreate: true }); + const response = await request("/automations", EXECUTOR, "POST", { ...createBody, teamId }); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ code: "team_required" }); + }); + + it("creates only for a member executor in an active team", async () => { + const created = await request("/automations", EXECUTOR, "POST", { + ...createBody, + teamId: TEAM_A, + }); + expect(created.status).toBe(201); + await expect(created.json()).resolves.toMatchObject({ + automation: { + ownerTeamId: TEAM_A, + userId: EXECUTOR, + capabilities: { canRead: true, canManage: true, canTrigger: true }, + }, + }); + const denied = await request("/automations", ADMIN, "POST", { ...createBody, teamId: TEAM_A }); + expect(denied.status).toBe(403); + await expect(denied.json()).resolves.toMatchObject({ reason_code: "not_member" }); + await env.DB.prepare("UPDATE teams SET archived_at = 2 WHERE id = ?").bind(TEAM_A).run(); + const archived = await request("/automations", EXECUTOR, "POST", { + ...createBody, + teamId: TEAM_A, + }); + expect(archived.status).toBe(409); + await expect(archived.json()).resolves.toMatchObject({ reason_code: "team_archived" }); + }); + + it("filters visibility before pagination and supports exact team/workspace filters", async () => { + await automation("hidden-newest", TEAM_B, 4); + await automation("visible-team", TEAM_A, 3); + await automation("visible-workspace", null, 2); + const page = await ( + await request("/automations?limit=1", MEMBER) + ).json<{ + automations: unknown[]; + nextCursor: string; + hasMore: boolean; + }>(); + expect(page).toMatchObject({ + automations: [ + { id: "visible-team", ownerTeamId: TEAM_A, capabilities: { canManage: false } }, + ], + hasMore: true, + }); + const next = await request( + `/automations?limit=1&cursor=${encodeURIComponent(page.nextCursor)}`, + MEMBER + ); + await expect(next.json()).resolves.toMatchObject({ + automations: [{ id: "visible-workspace" }], + hasMore: false, + nextCursor: null, + }); + for (const [teamId, ids] of [ + [TEAM_A, ["visible-team"]], + [TEAM_B, []], + ["null", ["visible-workspace"]], + ] as const) { + const response = await request(`/automations?teamId=${teamId}`, MEMBER); + const filtered = await response.json<{ automations: { id: string }[]; hasMore: boolean }>(); + expect(filtered.automations.map((row) => row.id)).toEqual(ids); + expect(filtered.hasMore).toBe(false); + } + const admin = await ( + await request("/automations", ADMIN) + ).json<{ automations: { id: string }[] }>(); + expect(admin.automations.map((row) => row.id)).toEqual([ + "hidden-newest", + "visible-team", + "visible-workspace", + ]); + }); + + describe("custom-any outsiders", () => { + beforeEach(async () => { + await automation("hidden-resource", TEAM_B); + await assignCustomRole(MEMBER, [ + "automations.read", + "automations.manage.any", + "automations.trigger.any", + ]); + }); + it.each(invisibleRoutes)( + "conceals $method /automations/:id$suffix without handler effects", + async ({ method, suffix, body }) => { + const original = await store.getById("hidden-resource"); + const hiddenPath = `/automations/hidden-resource${suffix}`; + const missingPath = `/automations/missing-resource${suffix}`; + for (const path of [hiddenPath, missingPath]) { + const response = await request(path, MEMBER, method, body); + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ error: "Automation not found" }); + } + expect(await store.getById("hidden-resource")).toEqual(original); + expect( + await env.DB.prepare("SELECT COUNT(*) AS count FROM automation_invocations").first() + ).toEqual({ count: 0 }); + expect(await env.DB.prepare("SELECT COUNT(*) AS count FROM sessions").first()).toEqual({ + count: 0, + }); + const denied = await env.DB.prepare( + `SELECT resource_id, team_id FROM authorization_audit_events + WHERE action = 'authorization.request_denied' AND operation_result = 'denied' + AND actor_user_id_snapshot = ? AND resource_id IN (?, ?)` + ) + .bind(MEMBER, hiddenPath, missingPath) + .all(); + expect(denied.results).toHaveLength(2); + expect(denied.results).toEqual( + expect.arrayContaining([ + { resource_id: hiddenPath, team_id: TEAM_B }, + { resource_id: missingPath, team_id: null }, + ]) + ); + } + ); + }); + + it("lets a lead manage another executor's automation without read permission", async () => { + await automation("no-read-management", TEAM_A); + await assignCustomRole(LEAD, ["automations.manage.own"]); + const read = await request("/automations/no-read-management", LEAD); + expect(read.status).toBe(403); + await expect(read.json()).resolves.toMatchObject({ reason_code: "missing_permission" }); + const update = await request("/automations/no-read-management", LEAD, "PUT", { + name: "Managed", + }); + expect(update.status).toBe(200); + await expect(update.json()).resolves.toMatchObject({ + automation: { + name: "Managed", + capabilities: { canRead: false, canManage: true }, + }, + }); + expect((await store.getById("no-read-management"))?.name).toBe("Managed"); + }); + + it("reserves executor changes for leads and audits only effective changes", async () => { + await automation("executor-change", TEAM_A); + const path = "/automations/executor-change"; + await expectStatus(request(path, EXECUTOR, "PATCH", { userId: MEMBER }), 403); + expect((await store.getById("executor-change"))?.user_id).toBe(EXECUTOR); + const changed = await request(path, LEAD, "PATCH", { + userId: MEMBER, + }); + expect(changed.status).toBe(200); + expect((await store.getById("executor-change"))?.user_id).toBe(MEMBER); + const audit = await env.DB.prepare( + "SELECT resource_type, resource_id, team_id, actor_user_id_snapshot, target_user_id_snapshot, metadata_json FROM authorization_audit_events WHERE action = 'automation.executor_changed' AND operation_result = 'applied'" + ).first(); + expect(audit).toMatchObject({ + resource_type: "automation", + resource_id: "executor-change", + team_id: TEAM_A, + actor_user_id_snapshot: LEAD, + target_user_id_snapshot: MEMBER, + }); + expect(JSON.parse(String(audit?.metadata_json))).toMatchObject({ + before: { userId: EXECUTOR }, + after: { userId: MEMBER }, + }); + await expectStatus(request(path, LEAD, "PATCH", { userId: MEMBER }), 200); + expect( + await env.DB.prepare( + "SELECT COUNT(*) AS count FROM authorization_audit_events WHERE action = 'automation.executor_changed'" + ).first() + ).toEqual({ count: 1 }); + }); + + it("rolls back an executor mutation when its batched audit fails", async () => { + await automation("executor-change", TEAM_A); + const row = (await store.getById("executor-change"))!; + await expect( + sqlDatabase(env.DB).batch([ + store.bindExecutorChange(row, MEMBER, LEAD), + env.DB.prepare("INSERT INTO authorization_audit_events (id) VALUES ('invalid-audit')"), + ]) + ).rejects.toThrow(); + expect((await store.getById(row.id))?.user_id).toBe(EXECUTOR); + }); + + it("allows administrator reassignment of a workspace automation", async () => { + await automation("workspace-executor", null); + const response = await request("/automations/workspace-executor", ADMIN, "PATCH", { + userId: MEMBER, + }); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + automation: { ownerTeamId: null, userId: MEMBER }, + }); + expect((await store.getById("workspace-executor"))?.user_id).toBe(MEMBER); + }); + + it.each(["missing", "suspended", "unassigned", "non-member", "provider identity"])( + "rejects an invalid executor (%s) without a mutation", + async (kind) => { + await automation("executor-change", TEAM_A); + if (kind === "suspended") + await env.DB.prepare("UPDATE users SET suspended_at = 2 WHERE id = ?").bind(MEMBER).run(); + if (kind === "unassigned") + await env.DB.prepare("DELETE FROM user_role_assignments WHERE user_id = ?") + .bind(MEMBER) + .run(); + const targets = { + missing: ["f".repeat(32), 404], + suspended: [MEMBER, 409], + unassigned: [MEMBER, 409], + "non-member": [ADMIN, 403], + "provider identity": ["github:583231", 400], + } as const; + const [userId, status] = targets[kind as keyof typeof targets]; + expect( + (await request("/automations/executor-change", LEAD, "PATCH", { userId })).status + ).toBe(status); + expect((await store.getById("executor-change"))?.user_id).toBe(EXECUTOR); + } + ); + + it.each([null, 71, 72])("uses numeric grant identity on target updates (%s)", async (repoId) => { + await automation("grant-update", TEAM_A); + await store.replaceRepositories("grant-update", [repository(repoId)]); + const body = { environmentIds: [] }; + await expectStatus(request("/automations/grant-update", EXECUTOR, "PUT", body), 409); + await seedGrant(TEAM_A, repository(71)); + const response = await request("/automations/grant-update", EXECUTOR, "PUT", { + environmentIds: [], + }); + expect(response.status).toBe(repoId === 71 ? 200 : 409); + if (repoId !== 71) + await expect(response.json()).resolves.toMatchObject({ code: "target_team_missing_grant" }); + expect((await store.getById("grant-update"))?.owner_team_id).toBe(TEAM_A); + await seedGrant(TEAM_A, "installation"); + await expectStatus(request("/automations/grant-update", EXECUTOR, "PUT", body), 200); + expect((await store.getRepositoriesForAutomation("grant-update"))[0].repo_id).toBe(repoId); + }); + + it("revalidates unchanged environment grants without use permission, but requires use for replacement", async () => { + await seedEnvironment("env_unchanged", TEAM_A, [repository(91)]); + await automation("repository-edit", TEAM_A); + await sqlDatabase(env.DB).batch( + store.bindEnvironmentInserts("repository-edit", ["env_unchanged"], 1) + ); + const original = await store.getEnvironmentsForAutomation("repository-edit"); + await assignCustomRole(LEAD, ["automations.manage.own", "repositories.use"]); + const path = "/automations/repository-edit"; + const missingGrant = await request(path, LEAD, "PUT", { + repositories: [], + }); + expect(missingGrant.status).toBe(409); + await expect(missingGrant.json()).resolves.toMatchObject({ + code: "target_team_missing_grant", + repository: "group/subgroup/api", + }); + await seedGrant(TEAM_A, repository(91)); + vi.spyOn(routeShared, "resolveRepoOrError").mockResolvedValue({ + repoId: 91, + repoOwner: "group/subgroup", + repoName: "api", + defaultBranch: "main", + }); + await expectStatus( + request(path, LEAD, "PUT", { + repositories: [{ repoOwner: "group/subgroup", repoName: "api" }], + }), + 200 + ); + expect((await store.getRepositoriesForAutomation("repository-edit"))[0].repo_id).toBe(91); + expect(await store.getEnvironmentsForAutomation("repository-edit")).toEqual(original); + const replacement = await request(path, LEAD, "PUT", { + environmentIds: ["env_unchanged"], + }); + expect(replacement.status).toBe(403); + await expect(replacement.json()).resolves.toMatchObject({ + code: "permission_required", + permission: "environments.use", + }); + }); + + it("conceals unchanged non-member environments in stored order without replacing selections", async () => { + const ids = ["env_hidden_z", "env_missing", "env_hidden_a"]; + await automation("unchanged-hidden", TEAM_A); + await sqlDatabase(env.DB).batch(store.bindEnvironmentInserts("unchanged-hidden", ids, 1)); + await assignCustomRole(LEAD, ["automations.manage.own"]); + for (const id of ["env_hidden_a", "env_hidden_z"]) await seedEnvironment(id, TEAM_B); + const hidden = await request("/automations/unchanged-hidden", LEAD, "PUT", { + repositories: [], + }); + expect(hidden.status).toBe(400); + expect(await hidden.text()).toBe( + JSON.stringify({ error: `Environment not found: ${[...ids].sort().join(", ")}` }) + ); + expect( + (await store.getEnvironmentsForAutomation("unchanged-hidden")).map( + (row) => row.environment_id + ) + ).toEqual([...ids].sort()); + }); + + it.each(["POST", "PUT"])( + "checks environment ownership through %s without changing selections", + async (method) => { + await automation("owner-check", TEAM_A); + await seedEnvironment("env_other_team", TEAM_B); + const original = await store.getById("owner-check"); + const path = method === "POST" ? "/automations" : "/automations/owner-check"; + const response = await request(path, EXECUTOR, method, { + ...createBody, + teamId: TEAM_A, + environmentIds: ["env_other_team"], + }); + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + reason_code: "environment_team_mismatch", + }); + expect(await store.getById("owner-check")).toEqual(original); + expect(await store.getEnvironmentsForAutomation("owner-check")).toEqual([]); + expect(await env.DB.prepare("SELECT COUNT(*) AS count FROM automations").first()).toEqual({ + count: 1, + }); + } + ); + + it("selects environments with use but no read through create and update", async () => { + await seedEnvironment("env_use_only", TEAM_A); + await assignCustomRole(LEAD, [ + "automations.create", + "automations.manage.own", + "environments.use", + // The lead is also the executor, which target edits require to stay runnable. + "sessions.create", + ]); + const created = await request("/automations", LEAD, "POST", { + ...createBody, + teamId: TEAM_A, + environmentIds: ["env_use_only"], + }); + expect(created.status).toBe(201); + const { automation: selected } = await created.json<{ automation: { id: string } }>(); + await expectStatus( + request(`/automations/${selected.id}`, LEAD, "PUT", { environmentIds: ["env_use_only"] }), + 200 + ); + expect( + (await store.getEnvironmentsForAutomation(selected.id)).map((row) => row.environment_id) + ).toEqual(["env_use_only"]); + }); + + it("checks grants for every selected environment repository on create and update", async () => { + await seedEnvironment("env_one", TEAM_A, [repository(11, "one")]); + await seedEnvironment("env_two", TEAM_A, [repository(22, "two")]); + await seedGrant(TEAM_A, repository(11, "one")); + const environmentIds = ["env_one", "env_two"]; + const body = { ...createBody, teamId: TEAM_A, environmentIds }; + expect((await request("/automations", EXECUTOR, "POST", body)).status).toBe(409); + await automation("targets-update", TEAM_A); + await expectStatus( + request("/automations/targets-update", EXECUTOR, "PUT", { environmentIds }), + 409 + ); + expect(await store.getEnvironmentsForAutomation("targets-update")).toEqual([]); + await seedGrant(TEAM_A, repository(22, "two")); + expect((await request("/automations", EXECUTOR, "POST", body)).status).toBe(201); + }); + + it("preserves actorless service read capabilities and mutation ceilings", async () => { + await automation("service-read", TEAM_A); + const url = "https://cp.test/automations/service-read"; + const read = await serviceFetch(url, { service: "slack-bot" }); + expect(read.status).toBe(200); + await expect(read.json()).resolves.toMatchObject({ + automation: { + ownerTeamId: TEAM_A, + capabilities: { canRead: true, canManage: false, canTrigger: false }, + }, + }); + for (const service of ["github-bot", "linear-bot"] as const) { + expect((await serviceFetch(url, { service })).status).toBe(403); + } + await expectStatus( + serviceFetch(url, { + service: "slack-bot", + method: "PATCH", + body: JSON.stringify({ userId: MEMBER }), + }), + 403 + ); + expect((await store.getById("service-read"))?.user_id).toBe(EXECUTOR); + }); +}); diff --git a/packages/control-plane/test/integration/child-session-ops.test.ts b/packages/control-plane/test/integration/child-session-ops.test.ts index bc36177527..4ef34093dc 100644 --- a/packages/control-plane/test/integration/child-session-ops.test.ts +++ b/packages/control-plane/test/integration/child-session-ops.test.ts @@ -4,6 +4,7 @@ import type { SessionStatus } from "@open-inspect/shared/types/sessions"; import { runInSessionDO } from "./session-do-access"; import type { SessionDO } from "../../src/cloudflare/durable-object"; import { SessionIndexStore } from "../../src/db/session-index"; +import { TeamMembershipStore } from "../../src/db/team-memberships"; import { cleanD1Tables } from "./cleanup"; import { initNamedSession, @@ -616,6 +617,52 @@ describe("Child session operations (list, get, cancel)", () => { expect((await store.get(childName))?.status).toBe("cancelled"); }); + it.each([ + ["prompt", { content: "Continue" }], + ["cancel", { cancelNested: false }], + ] as const)( + "requires the parent prompt author's current team membership to %s a team child", + async (action, body) => { + const { pName, childName, parentStub, childStub, sandboxToken, store } = + await setupParentAndChild({ childStatus: "active" }); + const authorId = "33333333333333333333333333333333"; + await serviceFetch("https://test.local/me/authorization", { + as: { userId: authorId, role: "member" }, + }); + await env.DB.prepare( + "INSERT INTO teams (id, slug, name, created_at, updated_at) VALUES ('team_child_ops', 'child-ops', 'Child Ops', 1, 1)" + ).run(); + await env.DB.prepare( + "UPDATE sessions SET owner_team_id = 'team_child_ops', visibility = 'team' WHERE id IN (?, ?)" + ) + .bind(pName, childName) + .run(); + await runInSessionDO(parentStub, (_instance: SessionDO, state) => { + state.storage.sql.exec( + "UPDATE participants SET canonical_user_id = ? WHERE role = 'owner'", + authorId + ); + }); + const url = `https://test.local/sessions/${pName}/children/${childName}/${action}`; + const init = { + method: "POST", + headers: { Authorization: `Bearer ${sandboxToken}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }; + + expect((await SELF.fetch(url, init)).status).toBe(404); + expect((await store.get(childName))?.status).toBe("active"); + const [messages] = await queryDO<{ count: number }>( + childStub, + "SELECT COUNT(*) AS count FROM messages" + ); + expect(messages?.count).toBe(0); + + await new TeamMembershipStore(env.DB).add("team_child_ops", authorId); + expect((await SELF.fetch(url, init)).status).toBe(200); + } + ); + it("checks a private grandchild before a human cancels any descendants", async () => { const { pName, childName, store } = await setupParentAndChild({ childStatus: "active" }); const grandchildName = await setupNestedSession(store, childName, 2, "private-grandchild"); diff --git a/packages/control-plane/test/integration/environment-secret-import-grants.test.ts b/packages/control-plane/test/integration/environment-secret-import-grants.test.ts new file mode 100644 index 0000000000..f05bbe8720 --- /dev/null +++ b/packages/control-plane/test/integration/environment-secret-import-grants.test.ts @@ -0,0 +1,221 @@ +import { env } from "cloudflare:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as tokenCrypto from "../../src/auth/crypto"; +import { EnvironmentSecretsStore } from "../../src/db/environment-secrets"; +import { EnvironmentStore, type EnvironmentRepositoryInsert } from "../../src/db/environments"; +import { RepoSecretsStore } from "../../src/db/repo-secrets"; +import * as routeShared from "../../src/routes/shared"; +import { cleanD1Tables } from "./cleanup"; +import { serviceRequestHeaders } from "./helpers"; +import { + seedEnvironment as seedOwnedEnvironment, + ownershipRequest, + seedGrant, + seedTeam, +} from "./ownership-test-helpers"; + +const BASE = "https://test.local"; +const WEB: EnvironmentRepositoryInsert = { + position: 0, + repo_owner: "acme/group", + repo_name: "web", + repo_id: 1, + base_branch: "main", +}; +const API = { ...WEB, position: 1, repo_name: "api", repo_id: 2 }; +const SOURCE = { repoOwner: WEB.repo_owner, repoName: WEB.repo_name, keys: ["TOKEN", "NEW_TOKEN"] }; +const store = new EnvironmentStore(env.DB); +const secrets = new EnvironmentSecretsStore(env.DB, env.REPO_SECRETS_ENCRYPTION_KEY!); +const resolvedRepo = { + repoId: 1, + repoOwner: WEB.repo_owner, + repoName: WEB.repo_name, + defaultBranch: "main", +}; + +async function seedEnvironment(ownerTeamId: string | null, repositories = [WEB]) { + const id = await seedOwnedEnvironment(`env_${crypto.randomUUID()}`, ownerTeamId, repositories); + await secrets.setSecrets(id, { + TOKEN: "original-environment-token", + KEEP: "keep-environment-token", + }); + return id; +} + +async function secretRows(id: string) { + const rows = await env.DB.prepare( + "SELECT * FROM environment_secrets WHERE environment_id = ? ORDER BY key" + ) + .bind(id) + .all(); + return rows.results; +} + +function importSecrets(id: string) { + return ownershipRequest(`/environments/${id}/secrets/import`, { + method: "POST", + body: JSON.stringify(SOURCE), + }); +} + +async function expectMissingSourceGrant(id: string) { + const before = await secretRows(id); + const repositories = await store.getRepositoriesForEnvironment(id); + const response = await importSecrets(id); + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + error: "Target team lacks repository grant", + code: "target_team_missing_grant", + repository: "acme/group/web", + }); + expect(EnvironmentSecretsStore.prototype.importFromRepo).not.toHaveBeenCalled(); + expect(tokenCrypto.decryptToken).not.toHaveBeenCalled(); + expect(await secretRows(id)).toEqual(before); + expect(await store.getRepositoriesForEnvironment(id)).toEqual(repositories); +} + +function expectSourceResolvedOnce() { + expect(routeShared.resolveRepoOrError).toHaveBeenCalledExactlyOnceWith( + expect.anything(), + WEB.repo_owner, + WEB.repo_name, + expect.anything(), + expect.anything() + ); +} + +async function expectSuccessfulImport(id: string) { + const response = await importSecrets(id); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + status: "imported", + environmentId: id, + source: "acme/group/web", + keys: expect.arrayContaining(SOURCE.keys), + created: 1, + updated: 1, + }); + expect(tokenCrypto.decryptToken).not.toHaveBeenCalled(); +} + +describe("environment secret import team grants", () => { + beforeEach(async () => { + await cleanD1Tables(); + await serviceRequestHeaders(`${BASE}/me/authorization`); + await new RepoSecretsStore(env.DB, env.REPO_SECRETS_ENCRYPTION_KEY!).setSecrets( + WEB.repo_id!, + WEB.repo_owner, + WEB.repo_name, + { TOKEN: "source-token", NEW_TOKEN: "new-source-token" } + ); + vi.spyOn(EnvironmentSecretsStore.prototype, "importFromRepo"); + vi.spyOn(tokenCrypto, "decryptToken"); + vi.spyOn(routeShared, "resolveRepoOrError").mockResolvedValue(resolvedRepo); + }); + afterEach(() => vi.restoreAllMocks()); + + it.each(["repository", "installation"] as const)( + "denies a revoked source %s grant even in a saved environment", + async (kind) => { + const teamId = await seedTeam(`team_revoked_${kind}`); + await seedGrant(teamId, kind === "installation" ? kind : WEB); + if (kind === "repository") await seedGrant(teamId, API); + const id = await seedEnvironment(teamId, [WEB, API]); + await env.DB.prepare( + "DELETE FROM team_repository_grants WHERE team_id = ? AND grant_kind = ? AND (repo_external_id = ? OR grant_kind = 'installation')" + ) + .bind(teamId, kind, WEB.repo_id) + .run(); + await expectMissingSourceGrant(id); + expectSourceResolvedOnce(); + } + ); + + it("does not authorize matching names with a different saved numeric grant", async () => { + const teamId = await seedTeam("team_numeric_mismatch"); + await seedGrant(teamId, { ...WEB, repo_id: 99 }); + await expectMissingSourceGrant(await seedEnvironment(teamId)); + expectSourceResolvedOnce(); + }); + + it.each(["repository", "installation"] as const)( + "does not use another team's %s grant for a workspace owner", + async (kind) => { + const target = await seedTeam("team_target"); + const other = await seedTeam("team_other"); + await seedGrant(other, kind === "installation" ? kind : WEB); + await expectMissingSourceGrant(await seedEnvironment(target)); + } + ); + + it("imports with only a numeric source grant despite stale names and ungranted secondary members", async () => { + const teamId = await seedTeam("team_source_only"); + await seedGrant(teamId, { ...WEB, repo_owner: "previous-owner", repo_name: "previous-name" }); + const id = await seedEnvironment(teamId, [WEB, { ...API, repo_id: null }]); + const before = await secretRows(id); + const repositories = await store.getRepositoriesForEnvironment(id); + await expectSuccessfulImport(id); + expectSourceResolvedOnce(); + const copied = await secretRows(id); + expect(copied.find((row) => row.key === "KEEP")).toEqual( + before.find((row) => row.key === "KEEP") + ); + // Import's no-decryption assertion above precedes this consumer read. + expect(await secrets.getDecryptedSecrets(id)).toEqual({ + TOKEN: "source-token", + NEW_TOKEN: "new-source-token", + KEEP: "keep-environment-token", + }); + expect(await store.getRepositoriesForEnvironment(id)).toEqual(repositories); + }); + + it.each([1, null])("allows an installation grant with saved source ID %s", async (repoId) => { + const teamId = await seedTeam("team_installation"); + await seedGrant(teamId, "installation"); + await expectSuccessfulImport(await seedEnvironment(teamId, [{ ...WEB, repo_id: repoId }, API])); + expectSourceResolvedOnce(); + }); + + it("preserves workspace imports without any team grant", async () => { + await expectSuccessfulImport(await seedEnvironment(null, [WEB, API])); + expectSourceResolvedOnce(); + }); + + it.each([1, 99])( + "checks the freshly resolved numeric identity %s for a null saved source ID", + async (resolvedId) => { + const teamId = await seedTeam("team_resolve_null"); + await seedGrant(teamId, WEB); + const id = await seedEnvironment(teamId, [{ ...WEB, repo_id: null }, API]); + vi.mocked(routeShared.resolveRepoOrError).mockResolvedValue({ + ...resolvedRepo, + repoId: resolvedId, + }); + if (resolvedId === WEB.repo_id) await expectSuccessfulImport(id); + else await expectMissingSourceGrant(id); + expectSourceResolvedOnce(); + expect((await store.getRepositoriesForEnvironment(id))[0].repo_id).toBeNull(); + } + ); + + it.each([404, 500])( + "fails closed on null-ID resolution failure %s despite matching grant names", + async (status) => { + const teamId = await seedTeam("team_unresolved"); + await seedGrant(teamId, WEB); + const id = await seedEnvironment(teamId, [{ ...WEB, repo_id: null }]); + const before = await secretRows(id); + vi.mocked(routeShared.resolveRepoOrError).mockRejectedValue( + new routeShared.HttpError("Resolution failed", status) + ); + expect((await importSecrets(id)).status).toBe(status); + expect(routeShared.resolveRepoOrError).toHaveBeenCalledTimes(1); + expect(EnvironmentSecretsStore.prototype.importFromRepo).not.toHaveBeenCalled(); + expect(tokenCrypto.decryptToken).not.toHaveBeenCalled(); + expect(await secretRows(id)).toEqual(before); + expect(await store.getRepositoriesForEnvironment(id)).toMatchObject([ + { ...WEB, repo_id: null }, + ]); + } + ); +}); diff --git a/packages/control-plane/test/integration/environment-secret-import-identity.test.ts b/packages/control-plane/test/integration/environment-secret-import-identity.test.ts new file mode 100644 index 0000000000..f0bb444c03 --- /dev/null +++ b/packages/control-plane/test/integration/environment-secret-import-identity.test.ts @@ -0,0 +1,179 @@ +import { createExecutionContext, env } from "cloudflare:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EnvironmentStore } from "../../src/db/environments"; +import { EnvironmentSecretsStore } from "../../src/db/environment-secrets"; +import { RepoSecretsStore } from "../../src/db/repo-secrets"; +import { TeamStore } from "../../src/db/teams"; +import { TeamRepositoryGrantStore } from "../../src/db/team-repository-grants"; +import { GitHubSourceControlProvider } from "../../src/source-control/providers/github-provider"; +import { cleanD1Tables } from "./cleanup"; +import { routeRequest, serviceRequestHeaders } from "./helpers"; + +const ENVIRONMENT_ID = "env_import_identity"; + +async function importSecrets() { + const url = `https://test.local/environments/${ENVIRONMENT_ID}/secrets/import`; + const init = { + method: "POST", + body: JSON.stringify({ repoOwner: "acme", repoName: "web" }), + }; + return routeRequest( + new Request(url, { ...init, headers: await serviceRequestHeaders(url, init) }), + env, + createExecutionContext() + ); +} + +async function seedImportEnvironment(repoId: number | null, grantedRepoId: number) { + const team = await new TeamStore(env.DB).create({ + slug: "importers", + name: "Importers", + joinPolicy: "invite_only", + }); + await new TeamRepositoryGrantStore(env.DB).add(team.id, { + kind: "repository", + repoExternalId: grantedRepoId, + owner: "acme", + name: "web", + }); + const store = new EnvironmentStore(env.DB); + await store.create( + { + id: ENVIRONMENT_ID, + owner_team_id: team.id, + name: "Import identity", + description: null, + prebuild_enabled: 0, + channel_associations: null, + created_at: 1, + updated_at: 1, + }, + [{ position: 0, repo_owner: "acme", repo_name: "web", repo_id: repoId, base_branch: "main" }] + ); + const repoSecretsStore = new RepoSecretsStore(env.DB, env.REPO_SECRETS_ENCRYPTION_KEY!); + await repoSecretsStore.setSecrets(123, "acme", "web", { + OLD_SOURCE_KEY: "old-source-value", + }); + const secretsStore = new EnvironmentSecretsStore(env.DB, env.REPO_SECRETS_ENCRYPTION_KEY!); + await secretsStore.setSecrets(ENVIRONMENT_ID, { EXISTING_KEY: "existing-value" }); + return { store, repoSecretsStore, secretsStore }; +} + +describe("environment secret import repository identity", () => { + beforeEach(async () => { + await cleanD1Tables(); + vi.spyOn(GitHubSourceControlProvider.prototype, "checkRepositoryAccess").mockResolvedValue({ + repoId: 456, + repoOwner: "acme", + repoName: "web", + defaultBranch: "main", + }); + }); + afterEach(() => vi.restoreAllMocks()); + + it("rejects a stale member ID despite its old grant and encrypted source secrets", async () => { + const { store, secretsStore } = await seedImportEnvironment(123, 123); + const membersBefore = await store.getRepositoriesForEnvironment(ENVIRONMENT_ID); + const keysBefore = await secretsStore.listSecretKeys(ENVIRONMENT_ID); + const source = await env.DB.prepare( + "SELECT key, encrypted_value FROM repo_secrets WHERE repo_id = ?" + ) + .bind(123) + .all<{ key: string; encrypted_value: string }>(); + expect(source.results).toHaveLength(1); + const teamGrants = vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam"); + const sourceGrants = vi.spyOn(TeamRepositoryGrantStore.prototype, "listTeamsForRepository"); + const importer = vi.spyOn(EnvironmentSecretsStore.prototype, "importFromRepo"); + + const response = await importSecrets(); + + expect(response.status).toBe(409); + const raw = await response.clone().text(); + expect(await response.json()).toEqual({ + error: "Repository identity changed", + code: "repository_identity_mismatch", + repository: "acme/web", + }); + expect(raw).not.toContain("old-source-value"); + for (const row of source.results) { + expect(raw).not.toContain(row.key); + expect(raw).not.toContain(row.encrypted_value); + } + expect(GitHubSourceControlProvider.prototype.checkRepositoryAccess).toHaveBeenCalledOnce(); + expect(GitHubSourceControlProvider.prototype.checkRepositoryAccess).toHaveBeenCalledWith({ + owner: "acme", + name: "web", + }); + expect(teamGrants).not.toHaveBeenCalled(); + expect(sourceGrants).not.toHaveBeenCalled(); + expect(importer).not.toHaveBeenCalled(); + expect(await secretsStore.listSecretKeys(ENVIRONMENT_ID)).toEqual(keysBefore); + expect(await secretsStore.getDecryptedSecrets(ENVIRONMENT_ID)).toEqual({ + EXISTING_KEY: "existing-value", + }); + expect(await store.getRepositoriesForEnvironment(ENVIRONMENT_ID)).toEqual(membersBefore); + }); + + it("imports only the current ID for a legacy null-ID member", async () => { + const { store, repoSecretsStore, secretsStore } = await seedImportEnvironment(null, 456); + const membersBefore = await store.getRepositoriesForEnvironment(ENVIRONMENT_ID); + await repoSecretsStore.setSecrets(456, "acme", "web", { + CURRENT_SOURCE_KEY: "current-source-value", + }); + const source = await env.DB.prepare( + "SELECT key, encrypted_value FROM repo_secrets WHERE repo_id = ?" + ) + .bind(456) + .all<{ key: string; encrypted_value: string }>(); + expect(source.results).toHaveLength(1); + + const response = await importSecrets(); + + expect(response.status).toBe(200); + const raw = await response.clone().text(); + expect(await response.json()).toEqual({ + status: "imported", + environmentId: ENVIRONMENT_ID, + source: "acme/web", + keys: ["CURRENT_SOURCE_KEY"], + created: 1, + updated: 0, + }); + expect(raw).not.toContain("OLD_SOURCE_KEY"); + expect(raw).not.toContain("old-source-value"); + expect(raw).not.toContain("current-source-value"); + for (const row of source.results) expect(raw).not.toContain(row.encrypted_value); + expect(GitHubSourceControlProvider.prototype.checkRepositoryAccess).toHaveBeenCalledOnce(); + expect(await secretsStore.getDecryptedSecrets(ENVIRONMENT_ID)).toEqual({ + EXISTING_KEY: "existing-value", + CURRENT_SOURCE_KEY: "current-source-value", + }); + const copied = await env.DB.prepare( + "SELECT key, encrypted_value FROM environment_secrets WHERE environment_id = ? AND key = ?" + ) + .bind(ENVIRONMENT_ID, "CURRENT_SOURCE_KEY") + .all<{ key: string; encrypted_value: string }>(); + expect(copied.results).toEqual(source.results); + expect(await store.getRepositoriesForEnvironment(ENVIRONMENT_ID)).toEqual(membersBefore); + }); + + it("returns 404 without copying secrets when the member repository disappears", async () => { + const { store, secretsStore } = await seedImportEnvironment(123, 123); + const membersBefore = await store.getRepositoriesForEnvironment(ENVIRONMENT_ID); + const keysBefore = await secretsStore.listSecretKeys(ENVIRONMENT_ID); + vi.mocked(GitHubSourceControlProvider.prototype.checkRepositoryAccess).mockResolvedValue(null); + + const response = await importSecrets(); + + expect(response.status).toBe(404); + const raw = await response.text(); + expect(raw).not.toContain("OLD_SOURCE_KEY"); + expect(raw).not.toContain("old-source-value"); + expect(GitHubSourceControlProvider.prototype.checkRepositoryAccess).toHaveBeenCalledOnce(); + expect(await secretsStore.listSecretKeys(ENVIRONMENT_ID)).toEqual(keysBefore); + expect(await secretsStore.getDecryptedSecrets(ENVIRONMENT_ID)).toEqual({ + EXISTING_KEY: "existing-value", + }); + expect(await store.getRepositoriesForEnvironment(ENVIRONMENT_ID)).toEqual(membersBefore); + }); +}); diff --git a/packages/control-plane/test/integration/environment-store.test.ts b/packages/control-plane/test/integration/environment-store.test.ts index eeebc33bca..fdd907d36b 100644 --- a/packages/control-plane/test/integration/environment-store.test.ts +++ b/packages/control-plane/test/integration/environment-store.test.ts @@ -14,6 +14,7 @@ import { type EnvironmentRepositoryInsert, } from "../../src/db/environments"; import { cleanD1Tables } from "./cleanup"; +import { TeamStore } from "../../src/db/teams"; function makeEnv(overrides?: Partial): EnvironmentRow { const now = Date.now(); @@ -54,6 +55,7 @@ describe("EnvironmentStore", () => { expect(environment.name).toBe("Full Stack"); expect(environment.description).toBe("web + api"); expect(environment.prebuildEnabled).toBe(true); + expect(environment.ownerTeamId).toBeNull(); expect(environment.repositories).toEqual([ { repoOwner: "acme", repoName: "web", repoId: 1, baseBranch: "main" }, { repoOwner: "acme", repoName: "api", repoId: 2, baseBranch: "develop" }, @@ -63,9 +65,9 @@ describe("EnvironmentStore", () => { it("resolves names case-insensitively via getByName", async () => { const store = new EnvironmentStore(env.DB); await store.create(makeEnv({ name: "Payments" }), repos(["acme", "web", 1, "main"])); - expect(await store.getByName("payments")).not.toBeNull(); - expect(await store.getByName("PAYMENTS")).not.toBeNull(); - expect(await store.getByName("other")).toBeNull(); + expect(await store.getByName("payments", null)).not.toBeNull(); + expect(await store.getByName("PAYMENTS", null)).not.toBeNull(); + expect(await store.getByName("other", null)).toBeNull(); }); it("rejects a case-insensitive duplicate name at the unique index", async () => { @@ -76,6 +78,40 @@ describe("EnvironmentStore", () => { ).rejects.toThrow(); }); + it("looks up same-named environments only within the exact owner scope", async () => { + const store = new EnvironmentStore(env.DB); + const teams = new TeamStore(env.DB); + const first = await teams.create({ slug: "first", name: "First", joinPolicy: "open" }); + const second = await teams.create({ slug: "second", name: "Second", joinPolicy: "open" }); + const workspaceRow = makeEnv({ name: "Scoped" }); + const firstRow = makeEnv({ name: "SCOPED", owner_team_id: first.id }); + const secondRow = makeEnv({ name: "scoped", owner_team_id: second.id }); + for (const row of [workspaceRow, firstRow, secondRow]) { + await store.create(row, repos(["acme", "web", 1, "main"])); + } + expect((await store.getByName("scoped", null))?.id).toBe(workspaceRow.id); + expect((await store.getByName("scoped", first.id))?.id).toBe(firstRow.id); + expect((await store.getByName("SCOPED", second.id))?.id).toBe(secondRow.id); + expect(toEnvironment(firstRow, []).ownerTeamId).toBe(first.id); + await expect( + store.create(makeEnv({ name: "Scoped", owner_team_id: first.id }), []) + ).rejects.toThrow(); + }); + + it("lists all scopes by default and exact team or workspace scopes when supplied", async () => { + const store = new EnvironmentStore(env.DB); + const team = await new TeamStore(env.DB).create({ + slug: "filtered", + name: "Filtered", + joinPolicy: "open", + }); + await store.create(makeEnv({ name: "Workspace" }), []); + await store.create(makeEnv({ name: "Team", owner_team_id: team.id }), []); + expect((await store.list()).total).toBe(2); + expect((await store.list(team.id)).environments.map((row) => row.name)).toEqual(["Team"]); + expect((await store.list(null)).environments.map((row) => row.name)).toEqual(["Workspace"]); + }); + it("lists environments newest-first", async () => { const store = new EnvironmentStore(env.DB); await store.create(makeEnv({ name: "A", created_at: 1000 }), repos(["acme", "web", 1, "main"])); diff --git a/packages/control-plane/test/integration/environment-team-ownership.test.ts b/packages/control-plane/test/integration/environment-team-ownership.test.ts new file mode 100644 index 0000000000..fda020b0cb --- /dev/null +++ b/packages/control-plane/test/integration/environment-team-ownership.test.ts @@ -0,0 +1,478 @@ +import { env } from "cloudflare:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PermissionId } from "@open-inspect/shared/rbac"; +import type { Environment } from "@open-inspect/shared/types/environments"; +import { EnvironmentStore, type EnvironmentRepositoryInsert } from "../../src/db/environments"; +import { TeamMembershipStore } from "../../src/db/team-memberships"; +import { TeamSettingsStore } from "../../src/db/team-settings"; +import * as saveHooks from "../../src/image-builds/save-hooks"; +import * as repositoryResolution from "../../src/repos/resolve"; +import { cleanD1Tables } from "./cleanup"; +import { seedImageRowForScope } from "./image-build-helpers"; +import { serviceRequestHeaders, type ServiceRequestInit } from "./helpers"; +import { + assignCustomRole, + expectStatus, + ownershipRequest, + seedEnvironment, + seedGrant, + seedTeam, +} from "./ownership-test-helpers"; + +const BASE = "https://test.local"; +const MEMBER = "22222222222222222222222222222222"; +const MANAGER_PERMISSIONS: PermissionId[] = [ + "environments.read", + "environments.manage", + "environments.use", + "environments.secrets.manage", + "environments.settings.manage", + "environments.images.manage", + "integrations.read", + "image_builds.read", + "repositories.use", +]; +const WEB: EnvironmentRepositoryInsert = { + position: 0, + repo_owner: "acme/group", + repo_name: "web", + repo_id: 1, + base_branch: "main", +}; +const API = { ...WEB, position: 1, repo_name: "api", repo_id: 2 }; +const CREATE_BODY = { + name: "Created", + repositories: [{ repoOwner: WEB.repo_owner, repoName: WEB.repo_name }], +}; +const store = new EnvironmentStore(env.DB); +const memberships = new TeamMembershipStore(env.DB); + +function request(path: string, method = "GET", body?: object, init: ServiceRequestInit = {}) { + return ownershipRequest(path, { + method, + ...init, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} +function removePermission(permission: PermissionId) { + return env.DB.prepare("DELETE FROM role_permissions WHERE role_id = ? AND permission_id = ?") + .bind(`role_custom_${MEMBER}`, permission) + .run(); +} +function addPermission(permission: PermissionId) { + return env.DB.prepare("INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)") + .bind(`role_custom_${MEMBER}`, permission) + .run(); +} +function resolveRepositoryIds(repoIdFor: (repoName: string) => number) { + vi.spyOn(repositoryResolution, "resolveSessionRepositories").mockImplementation( + async (_env, repositories) => + repositories.map((repo) => ({ + ...repo, + repoId: repoIdFor(repo.repoName), + baseBranch: repo.baseBranch ?? "main", + })) + ); +} +function memberRequest(path: string, method = "GET", body?: object) { + return request(path, method, body, { as: { userId: MEMBER, role: "member" } }); +} +async function team(slug: string, installation = true) { + const id = await seedTeam(`team_${slug}`); + if (installation) await seedGrant(id, "installation"); + return id; +} +function environment( + ownerTeamId: string | null, + name = "Seeded", + repositories = [WEB], + prebuildEnabled = false +) { + return seedEnvironment(`env_${crypto.randomUUID()}`, ownerTeamId, repositories, { + name, + prebuild_enabled: prebuildEnabled ? 1 : 0, + }); +} + +describe("environment team ownership", () => { + beforeEach(async () => { + await cleanD1Tables(); + await serviceRequestHeaders(`${BASE}/me/authorization`); + await serviceRequestHeaders(`${BASE}/me/authorization`, { + as: { userId: MEMBER, role: "member" }, + }); + await assignCustomRole(MEMBER, MANAGER_PERMISSIONS); + resolveRepositoryIds((repoName) => (repoName === "web" ? 1 : 2)); + vi.spyOn(saveHooks, "scheduleImageBuildOnSave").mockImplementation(() => {}); + }); + afterEach(() => vi.restoreAllMocks()); + + it.each([undefined, null])("enforces workspace CRUD/team settings (%s)", async (teamId) => { + await env.DB.prepare( + "DELETE FROM role_permissions WHERE role_id = ? AND permission_id != 'environments.manage'" + ) + .bind(`role_custom_${MEMBER}`) + .run(); + const capabilities = { canRead: false, canManage: true, canUse: false }; + const unpermitted = await memberRequest("/environments", "POST", { ...CREATE_BODY, teamId }); + expect(unpermitted.status).toBe(403); + expect(await unpermitted.json()).toMatchObject({ + code: "permission_required", + permission: "repositories.use", + }); + expect(repositoryResolution.resolveSessionRepositories).not.toHaveBeenCalled(); + await addPermission("repositories.use"); + const response = await memberRequest("/environments", "POST", { ...CREATE_BODY, teamId }); + expect(response.status).toBe(201); + const { environment: created } = await response.json<{ environment: Environment }>(); + expect(created).toMatchObject({ ownerTeamId: null, capabilities }); + const path = `/environments/${created.id}`; + const read = await memberRequest(path); + expect(read.status).toBe(403); + expect(await read.json()).toMatchObject({ reason_code: "missing_permission" }); + const updated = await memberRequest(path, "PUT", { description: "Managed" }); + expect(updated.status).toBe(200); + expect(await updated.json()).toMatchObject({ + environment: { description: "Managed", capabilities }, + }); + expect((await store.getById(created.id))?.description).toBe("Managed"); + await expectStatus(memberRequest(path, "DELETE"), 200); + expect(await store.getById(created.id)).toBeNull(); + await new TeamSettingsStore(env.DB).set({ requireTeamOnCreate: true }); + vi.mocked(repositoryResolution.resolveSessionRepositories).mockClear(); + const denied = await memberRequest("/environments", "POST", { ...CREATE_BODY, teamId }); + expect(denied.status).toBe(400); + expect(await denied.json()).toMatchObject({ code: "team_required" }); + expect(repositoryResolution.resolveSessionRepositories).not.toHaveBeenCalled(); + }); + + it("requires lead authority to create a team environment even with the manage grant", async () => { + const id = await team("creation"); + const body = { ...CREATE_BODY, teamId: id }; + expect((await memberRequest("/environments", "POST", body)).status).toBe(403); + await memberships.add(id, MEMBER); + const denied = await memberRequest("/environments", "POST", body); + expect(denied.status).toBe(403); + expect(await denied.json()).toMatchObject({ reason_code: "not_owner_or_lead" }); + expect(repositoryResolution.resolveSessionRepositories).not.toHaveBeenCalled(); + await memberships.setRole(id, MEMBER, "lead"); + const created = await memberRequest("/environments", "POST", body); + expect(created.status).toBe(201); + expect(await created.json()).toMatchObject({ + environment: { + ownerTeamId: id, + capabilities: { canRead: true, canManage: true, canUse: true }, + }, + }); + }); + + it("rejects missing/archived create teams before repository resolution", async () => { + expect( + (await request("/environments", "POST", { ...CREATE_BODY, teamId: "team_missing" })).status + ).toBe(404); + const id = await team("archived"); + await env.DB.prepare("UPDATE teams SET archived_at = 2 WHERE id = ?").bind(id).run(); + const response = await request("/environments", "POST", { ...CREATE_BODY, teamId: id }); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ reason_code: "team_archived" }); + expect(repositoryResolution.resolveSessionRepositories).not.toHaveBeenCalled(); + }); + + it("scopes case-insensitive create/rename conflicts to the owner and permits self-renames", async () => { + const first = await team("first"); + const second = await team("second"); + await environment(null, "Taken"); + await environment(first, "Taken"); + const body = { ...CREATE_BODY, name: "taken", teamId: second }; + const response = await request("/environments", "POST", body); + expect(response.status).toBe(201); + const { environment: created } = await response.json<{ environment: Environment }>(); + expect((await request("/environments", "POST", { ...body, name: "TAKEN" })).status).toBe(409); + const rename = await environment(second, "Before"); + expect((await request(`/environments/${rename}`, "PUT", { name: "Taken" })).status).toBe(409); + await expectStatus(request(`/environments/${created.id}`, "PUT", { name: "TAKEN" }), 200); + expect((await request(`/environments/${rename}`, "PUT", { name: "Other" })).status).toBe(200); + }); + + it("filters lists by viewer and exact scope with visible totals/capabilities", async () => { + const visible = await team("visible"); + const hidden = await team("hidden"); + await memberships.add(visible, MEMBER); + const workspaceId = await environment(null, "Workspace"); + const visibleId = await environment(visible, "Visible"); + await environment(hidden, "Hidden"); + const list = await ( + await memberRequest("/environments") + ).json<{ environments: Environment[]; total: number }>(); + expect(list.total).toBe(2); + expect(list.environments.map((row) => row.id).sort()).toEqual([workspaceId, visibleId].sort()); + for (const row of list.environments) { + expect(row.capabilities).toEqual({ + canRead: true, + canManage: row.ownerTeamId === null, + canUse: true, + }); + } + for (const [ownerTeamId, ids] of [ + [visible, [visibleId]], + [hidden, []], + ["null", [workspaceId]], + ] as const) { + const page = await ( + await memberRequest(`/environments?ownerTeamId=${ownerTeamId}`) + ).json<{ environments: Environment[]; total: number }>(); + expect(page.total).toBe(ids.length); + expect(page.environments.map((row) => row.id)).toEqual(ids); + } + await expectStatus( + memberRequest(`/environments?ownerTeamId=${visible}&ownerTeamId=${hidden}`), + 400 + ); + // The team session catalog excludes environments other teams own, even when granted. + for (const send of [memberRequest, request]) { + const catalog = await ( + await send(`/environments?teamId=${visible}`) + ).json<{ environments: Environment[]; total: number }>(); + expect(catalog.total).toBe(2); + expect(catalog.environments.map((row) => row.id).sort()).toEqual( + [workspaceId, visibleId].sort() + ); + } + }); + + it("conceals outsider CRUD and denies member management without mutating the row", async () => { + const teamId = await team("item-access"); + const id = await environment(teamId); + const original = await store.getById(id); + for (const method of ["GET", "PUT", "DELETE"]) { + const response = await memberRequest( + `/environments/${id}`, + method, + method === "PUT" ? {} : undefined + ); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "Environment not found" }); + } + await memberships.add(teamId, MEMBER); + expect(await (await memberRequest(`/environments/${id}`)).json()).toMatchObject({ + environment: { + capabilities: { canRead: true, canManage: false, canUse: true }, + }, + }); + const denied = await memberRequest(`/environments/${id}`, "PUT", { + description: "Must not save", + }); + expect(denied.status).toBe(403); + expect(await denied.json()).toMatchObject({ reason_code: "not_owner_or_lead" }); + expect(await store.getById(id)).toEqual(original); + expect(repositoryResolution.resolveSessionRepositories).not.toHaveBeenCalled(); + }); + + it("validates resolved grants on create, repository replacement, and prebuild edits but permits repair", async () => { + const teamId = await team("revoked", false); + await seedGrant(teamId, WEB); + const repositories = [WEB, API].map((repo) => ({ + repoOwner: repo.repo_owner, + repoName: repo.repo_name, + })); + const create = await request("/environments", "POST", { ...CREATE_BODY, teamId, repositories }); + expect(create.status).toBe(409); + expect(await create.json()).toMatchObject({ + code: "target_team_missing_grant", + repository: "acme/group/api", + }); + expect((await store.list()).total).toBe(0); + const id = await environment(teamId, "Revoked", [WEB, API], true); + for (const body of [ + { description: "Must not save" }, + { description: "Must not save", prebuildEnabled: true }, + { description: "Must not save", repositories }, + ]) { + const denied = await request(`/environments/${id}`, "PUT", body); + expect(denied.status).toBe(409); + expect(await denied.json()).toMatchObject({ + code: "target_team_missing_grant", + repository: "acme/group/api", + }); + expect((await store.getById(id))?.description).toBeNull(); + } + expect(saveHooks.scheduleImageBuildOnSave).not.toHaveBeenCalled(); + // Scalar edits that leave prebuilds disabled stay available on revoked environments. + const disabled = await request(`/environments/${id}`, "PUT", { + description: "Prebuilds off", + prebuildEnabled: false, + }); + expect(disabled.status).toBe(200); + expect(await disabled.json()).toMatchObject({ + environment: { description: "Prebuilds off", prebuildEnabled: false }, + }); + expect((await store.getRepositoriesForEnvironment(id)).map((repo) => repo.repo_id)).toEqual([ + 1, 2, + ]); + const repaired = await request(`/environments/${id}`, "PUT", { + repositories: CREATE_BODY.repositories, + prebuildEnabled: true, + }); + expect(repaired.status).toBe(200); + expect(await repaired.json()).toMatchObject({ + environment: { capabilities: { canManage: true }, repositories: [{ repoId: 1 }] }, + }); + expect((await store.getRepositoriesForEnvironment(id)).map((repo) => repo.repo_id)).toEqual([ + 1, + ]); + expect(saveHooks.scheduleImageBuildOnSave).toHaveBeenCalledOnce(); + }); + + it("requires numeric repository IDs despite matching names and repairs to resolved identity", async () => { + const teamId = await team("numeric", false); + await seedGrant(teamId, { ...WEB, repo_owner: "ACME/GROUP", repo_name: "WEB" }); + const id = await environment(teamId, "Numeric", [WEB], true); + resolveRepositoryIds(() => 99); + for (const body of [ + { description: "Denied" }, + { description: "Denied", repositories: CREATE_BODY.repositories }, + ]) { + const denied = await request(`/environments/${id}`, "PUT", body); + expect(denied.status).toBe(409); + expect(await denied.json()).toMatchObject({ + code: "target_team_missing_grant", + repository: "acme/group/web", + }); + expect((await store.getById(id))?.description).toBeNull(); + expect((await store.getRepositoriesForEnvironment(id))[0].repo_id).toBe(1); + } + resolveRepositoryIds(() => 1); + const updated = await request(`/environments/${id}`, "PUT", { + repositories: CREATE_BODY.repositories, + description: "Canonical", + }); + expect(updated.status).toBe(200); + expect(await updated.json()).toMatchObject({ + environment: { description: "Canonical", repositories: [{ repoId: 1 }] }, + }); + await store.replaceRepositories(id, [{ ...WEB, repo_id: null }]); + resolveRepositoryIds(() => 99); + await seedGrant(teamId, "installation"); + await expectStatus(request(`/environments/${id}`, "PUT", { description: "Granted" }), 200); + expect((await store.getRepositoriesForEnvironment(id))[0].repo_id).toBeNull(); + }); + + it("conceals environment adjunct routes and keeps their extra permission requirements", async () => { + const teamId = await team("adjunct"); + const id = await environment(teamId); + const secretsPath = `/environments/${id}/secrets`; + const settingsPath = `/integration-settings/sandbox/environments/${id}`; + const secrets = { secrets: { TOKEN: "value" } }; + const settings = { settings: { terminalEnabled: true } }; + const paths = [ + ["GET", secretsPath, undefined], + ["PUT", secretsPath, secrets], + ["DELETE", `/environments/${id}/secrets/TOKEN`, undefined], + [ + "POST", + `/environments/${id}/secrets/import`, + { repoOwner: WEB.repo_owner, repoName: WEB.repo_name }, + ], + ["GET", settingsPath, undefined], + ["PUT", settingsPath, settings], + ["DELETE", settingsPath, undefined], + ["POST", `/image-builds/trigger/environment/${id}`, undefined], + ] as const; + for (const [method, path, body] of paths) { + expect((await memberRequest(path, method, body)).status, `${method} ${path}`).toBe(404); + } + await memberships.add(teamId, MEMBER, "lead"); + await removePermission("environments.read"); + await expectStatus(memberRequest(secretsPath), 403); + await expectStatus(memberRequest(secretsPath, "PUT", secrets), 200); + await expectStatus(memberRequest(settingsPath, "PUT", settings), 200); + await removePermission("environments.secrets.manage"); + await expectStatus(memberRequest(secretsPath, "PUT", secrets), 403); + }); + + it("filters image status/enabled feeds and conceals unauthorized explicit scopes", async () => { + const visible = await team("images-visible"); + await memberships.add(visible, MEMBER); + const visibleId = await environment(visible, "Visible images", [WEB], true); + const hiddenId = await environment(await team("images-hidden"), "Hidden images", [WEB], true); + for (const id of [visibleId, hiddenId]) { + await seedImageRowForScope( + { kind: "environment", id }, + { id: `image_${id}`, status: "ready" } + ); + } + const status = await memberRequest("/image-builds/status"); + const enabled = await memberRequest("/image-builds/enabled"); + expect(await status.json()).toMatchObject({ images: [{ scopeId: visibleId }] }); + expect(await enabled.json()).toMatchObject({ units: [{ scopeId: visibleId }] }); + const explicit = (id: string) => `/image-builds/status?scope_kind=environment&scope_id=${id}`; + expect((await memberRequest(explicit(hiddenId))).status).toBe(404); + expect((await memberRequest(explicit(visibleId))).status).toBe(200); + await removePermission("environments.read"); + expect(await (await memberRequest("/image-builds/status")).json()).toEqual({ images: [] }); + expect(await (await memberRequest("/image-builds/enabled")).json()).toMatchObject({ + units: [], + }); + const forbidden = await memberRequest(explicit(visibleId)); + expect(forbidden.status).toBe(403); + expect(await forbidden.json()).toMatchObject({ reason_code: "missing_permission" }); + expect((await memberRequest(explicit(hiddenId))).status).toBe(404); + }); + + it("preserves distinct actorless list/item ceilings and service capabilities", async () => { + const id = await environment(await team("bot-read")); + const workspaceId = await environment(null, "Workspace"); + const capabilities = { canRead: true, canManage: false, canUse: true }; + for (const service of ["slack-bot", "linear-bot"] as const) { + // Actorless catalogs omit team environments; bot sessions are workspace-owned. + const list = await request("/environments", "GET", undefined, { service }); + expect(list.status).toBe(200); + expect(await list.json()).toMatchObject({ + environments: [{ id: workspaceId, capabilities }], + total: 1, + }); + await expectStatus(request(`/environments/${id}`, "GET", undefined, { service }), 403); + } + await expectStatus(request("/environments", "GET", undefined, { service: "github-bot" }), 403); + // Item reads match the catalog: team environments look missing to actorless bots. + const hidden = await request(`/environments/${id}`, "GET", undefined, { + service: "github-bot", + }); + const missing = await request("/environments/env_missing", "GET", undefined, { + service: "github-bot", + }); + expect(hidden.status).toBe(404); + expect(await hidden.json()).toEqual(await missing.json()); + const response = await request(`/environments/${workspaceId}`, "GET", undefined, { + service: "github-bot", + }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + environment: { id: workspaceId, capabilities }, + }); + }); + + it("conceals another team's environment from skill resolution previews", async () => { + await addPermission("skills.read"); + const hidden = await environment(await team("preview-hidden")); + const visibleTeam = await team("preview-visible"); + await memberships.add(visibleTeam, MEMBER, "member"); + const visible = await environment(visibleTeam); + const preview = (environmentId: string) => + memberRequest("/skills/resolve-preview", "POST", { environmentId }); + + const missing = await preview("env_missing"); + const concealed = await preview(hidden); + expect(concealed.status).toBe(404); + expect(await concealed.json()).toEqual(await missing.json()); + await expectStatus(preview(visible), 200); + await removePermission("environments.read"); + const unreadable = await preview(visible); + expect(unreadable.status).toBe(403); + expect(await unreadable.json()).toEqual({ + error: "Forbidden", + code: "environment_action_denied", + reason_code: "missing_permission", + }); + }); +}); diff --git a/packages/control-plane/test/integration/environments-catalog.test.ts b/packages/control-plane/test/integration/environments-catalog.test.ts new file mode 100644 index 0000000000..a16908a79d --- /dev/null +++ b/packages/control-plane/test/integration/environments-catalog.test.ts @@ -0,0 +1,167 @@ +import { env } from "cloudflare:test"; +import { beforeEach, describe, expect, it } from "vitest"; +import { EnvironmentStore } from "../../src/db/environments"; +import { TeamMembershipStore } from "../../src/db/team-memberships"; +import { TeamRepositoryGrantStore } from "../../src/db/team-repository-grants"; +import { TeamStore } from "../../src/db/teams"; +import { cleanD1Tables } from "./cleanup"; +import { seedActiveUser, serviceFetch } from "./helpers"; + +const BASE = "https://test.local/environments"; +const MEMBER = "22222222222222222222222222222222"; +const OTHER = "33333333333333333333333333333333"; + +describe("team-scoped environment catalog", () => { + let teamId: string; + + beforeEach(async () => { + await cleanD1Tables(); + await seedActiveUser(MEMBER); + await seedActiveUser(OTHER); + teamId = ( + await new TeamStore(env.DB).create({ + slug: "engineering", + name: "Engineering", + joinPolicy: "invite_only", + }) + ).id; + await new TeamMembershipStore(env.DB).add(teamId, MEMBER); + const targets: [string, (number | null)[]][] = [ + ["covered", [1]], + ["denied", [2]], + ["multi", [1, 2]], + ["nullable", [null]], + ["empty", []], + ]; + for (const [name, repoIds] of targets) { + await new EnvironmentStore(env.DB).create( + { + id: `env_${name}`, + owner_team_id: name === "denied" ? teamId : null, + name, + description: null, + prebuild_enabled: 0, + channel_associations: null, + created_at: 1, + updated_at: 1, + }, + repoIds.map((repoId, position) => ({ + position, + repo_owner: "acme", + repo_name: `repo-${repoId ?? 1}`, + repo_id: repoId, + base_branch: "main", + })) + ); + } + }); + + it("filters persisted refs without SCM configuration, and retains the workspace catalog", async () => { + const scopedUrl = `${BASE}?teamId=${teamId}`; + const member = { as: { userId: MEMBER, role: "member" as const } }; + const grants = new TeamRepositoryGrantStore(env.DB); + expect(await (await serviceFetch(scopedUrl, member)).json()).toEqual({ + environments: [], + total: 0, + }); + const first = await grants.add(teamId, { + kind: "repository", + repoExternalId: 1, + owner: "acme", + name: "repo-1", + }); + + const response = await serviceFetch(scopedUrl, member); + expect(response.status).toBe(200); + const covered = await response.json<{ + environments: { id: string; repositories: { repoId: number | null }[] }[]; + total: number; + }>(); + expect(covered.total).toBe(1); + expect(covered.environments.map((row) => row.id)).toEqual(["env_covered"]); + expect(covered.environments[0].repositories.map((repo) => repo.repoId)).toEqual([1]); + expect(await (await serviceFetch(BASE, member)).json()).toMatchObject({ total: 5 }); + + const second = await grants.add(teamId, { + kind: "repository", + repoExternalId: 2, + owner: "acme", + name: "repo-2", + }); + const allNumeric = await ( + await serviceFetch(scopedUrl, member) + ).json<{ + environments: { id: string }[]; + total: number; + }>(); + expect(allNumeric.total).toBe(3); + expect(allNumeric.environments.map((row) => row.id).sort()).toEqual([ + "env_covered", + "env_denied", + "env_multi", + ]); + + await grants.remove(teamId, first.id); + await grants.remove(teamId, second.id); + await grants.add(teamId, { kind: "installation" }); + const installation = await ( + await serviceFetch(scopedUrl, member) + ).json<{ + environments: { id: string }[]; + total: number; + }>(); + expect(installation.total).toBe(4); + expect(installation.environments.map((row) => row.id).sort()).toEqual([ + "env_covered", + "env_denied", + "env_multi", + "env_nullable", + ]); + }); + + it("audits concealed nonmember, missing, and archived scopes through the existing writer", async () => { + const nonmember = { as: { userId: OTHER, role: "member" as const } }; + const responses = [ + await serviceFetch(`${BASE}?teamId=${teamId}`, nonmember), + await serviceFetch(`${BASE}?teamId=team_missing`, nonmember), + ]; + await new TeamStore(env.DB).archive(teamId); + responses.push( + await serviceFetch(`${BASE}?teamId=${teamId}`, { + as: { userId: MEMBER, role: "member" }, + }), + await serviceFetch(`${BASE}?teamId=${teamId}`) + ); + for (const response of responses) { + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "Team not found" }); + } + + const audit = await env.DB.prepare( + "SELECT action, reason_code, operation_result, metadata_json, team_id FROM authorization_audit_events WHERE resource_id = '/environments' AND operation_result = 'denied'" + ).all<{ + action: string; + reason_code: string; + operation_result: string; + metadata_json: string; + team_id: string; + }>(); + expect(audit.results).toHaveLength(4); + expect(audit.results.map((row) => row.team_id).sort()).toEqual( + [teamId, "team_missing", teamId, teamId].sort() + ); + for (const row of audit.results) { + expect(row).toMatchObject({ + action: "authorization.request_denied", + reason_code: "team_not_visible", + operation_result: "denied", + }); + expect(JSON.parse(row.metadata_json)).toMatchObject({ + httpMethod: "GET", + httpPath: "/environments", + httpStatus: 404, + requirements: [{ kind: "team", teamIdParam: "teamId", need: "member" }], + }); + } + }); +}); diff --git a/packages/control-plane/test/integration/environments-routes.test.ts b/packages/control-plane/test/integration/environments-routes.test.ts index 1c8bd4b94b..b36b80ff65 100644 --- a/packages/control-plane/test/integration/environments-routes.test.ts +++ b/packages/control-plane/test/integration/environments-routes.test.ts @@ -8,12 +8,13 @@ * exist are seeded directly via EnvironmentStore (mirroring PR-4's split). */ -import { describe, it, expect, beforeEach } from "vitest"; -import { SELF, env } from "cloudflare:test"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { SELF, env, createExecutionContext } from "cloudflare:test"; import { EnvironmentStore } from "../../src/db/environments"; import { RepoSecretsStore } from "../../src/db/repo-secrets"; +import { GitHubSourceControlProvider } from "../../src/source-control/providers/github-provider"; import { cleanD1Tables } from "./cleanup"; -import { serviceFetch } from "./helpers"; +import { routeRequest, serviceFetch, serviceRequestHeaders } from "./helpers"; const BASE = "https://test.local"; @@ -52,6 +53,7 @@ async function seedEnvironment(opts?: { describe("Environments API (routes)", () => { beforeEach(cleanD1Tables); + afterEach(() => vi.restoreAllMocks()); describe("auth", () => { it("returns 401 without internal auth", async () => { @@ -280,11 +282,27 @@ describe("Environments API (routes)", () => { } ); - const res = await serviceFetch(`${BASE}/environments/${id}/secrets/import`, { + const checkRepositoryAccess = vi + .spyOn(GitHubSourceControlProvider.prototype, "checkRepositoryAccess") + .mockResolvedValue({ + repoId: 1, + repoOwner: "acme", + repoName: "web", + defaultBranch: "main", + }); + const url = `${BASE}/environments/${id}/secrets/import`; + const init = { method: "POST", body: JSON.stringify({ repoOwner: " ACME ", repoName: " WEB ", keys: ["DEPLOY_KEY"] }), - }); + }; + const res = await routeRequest( + new Request(url, { ...init, headers: await serviceRequestHeaders(url, init) }), + env, + createExecutionContext() + ); expect(res.status).toBe(200); + expect(checkRepositoryAccess).toHaveBeenCalledOnce(); + expect(checkRepositoryAccess).toHaveBeenCalledWith({ owner: "acme", name: "web" }); const raw = await res.text(); // Value-free: neither plaintext nor ciphertext leaks into the response. expect(raw).not.toContain("supersecretvalue"); diff --git a/packages/control-plane/test/integration/github-reviewer-token.test.ts b/packages/control-plane/test/integration/github-reviewer-token.test.ts index b378288b00..927200fd9a 100644 --- a/packages/control-plane/test/integration/github-reviewer-token.test.ts +++ b/packages/control-plane/test/integration/github-reviewer-token.test.ts @@ -1,24 +1,35 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createExecutionContext, env } from "cloudflare:test"; import type { WorkerBindings } from "../../src/cloudflare/platform"; -import { INSTALLATION_TOKEN_CACHE_MAX_AGE_MS } from "../../src/auth/github-app"; +import { + getInstallationTokenCacheKey, + INSTALLATION_TOKEN_CACHE_MAX_AGE_MS, +} from "../../src/auth/github-app"; import { cleanD1Tables } from "./cleanup"; import { initNamedSession, routeRequest, seedSandboxAuth, serviceFetch } from "./helpers"; +/** The reviewed repository: the helper's default session repository. */ +const REVIEWED_REPO_ID = 12345; + /** - * A token already in the installation-token cache, so the route answers - * without an installation-token exchange. The integration outbound service - * throws on any unexpected request, so a route that reached for the wrong - * App's credential would fail loudly rather than return the wrong token. + * A token already in the installation-token cache for one repository, so the route answers + * without an installation-token exchange. The integration outbound service throws on any + * unexpected request, so a route that reached for the wrong App's credential, or a wider scope, + * would fail loudly rather than return the wrong token. */ async function cacheInstallationToken( appId: string, installationId: string, - token: string + token: string, + repoId = REVIEWED_REPO_ID ): Promise { const now = Date.now(); + const key = await getInstallationTokenCacheKey( + { appId, installationId, privateKey: "" }, + { kind: "repositories", repositoryIds: [repoId] } + ); await env.REPOS_CACHE.put( - `github:installation-token:v1:${appId}:${installationId}`, + key, JSON.stringify({ token, expiresAtEpochMs: now + INSTALLATION_TOKEN_CACHE_MAX_AGE_MS, @@ -50,12 +61,12 @@ function fetchReviewToken(sessionName: string, token: string, bindings: WorkerBi * Fork-only (depends on #1370's github_review_sessions): register `sessionId` as a review session, * the way a fenced create does, so the broker treats it as one. */ -async function registerReviewFence(sessionId: string): Promise { +async function registerReviewFence(sessionId: string, repoId = REVIEWED_REPO_ID): Promise { await env.DB.prepare( `INSERT INTO github_review_sessions (repo_id, pr_number, generation, session_id, head_sha, created_at) - VALUES (1, 1, 1, ?, 'sha', ?)` + VALUES (?, 1, 1, ?, 'sha', ?)` ) - .bind(sessionId, Date.now()) + .bind(repoId, sessionId, Date.now()) .run(); } @@ -67,7 +78,7 @@ describe("reviewer app token broker", () => { await cleanD1Tables(); }); - it("mints the reviewer App's token for a GitHub bot session's own sandbox", async () => { + it("mints the reviewer App's token for the reviewed repository alone", async () => { const suffix = `${Date.now()}`; const sessionName = `review-token-${suffix}`; const keyPair = (await crypto.subtle.generateKey( @@ -90,6 +101,8 @@ describe("reviewer app token broker", () => { const claims = JSON.parse(atob(jwt.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))); expect(claims.iss).toBe(`reviewer-${suffix}`); expect(init?.method).toBe("POST"); + // The session also carries a context repository the reviewer App need not cover. + expect(JSON.parse(String(init?.body))).toEqual({ repository_ids: [REVIEWED_REPO_ID] }); return Response.json({ token: "reviewer-installation-token", expires_at: new Date(Date.now() + 3_600_000).toISOString(), @@ -101,7 +114,13 @@ describe("reviewer app token broker", () => { // token: the review POST must be authenticated as the reviewer App. await cacheInstallationToken(`main-${suffix}`, `mi-${suffix}`, "main-installation-token"); - const { stub } = await initNamedSession(sessionName, { spawnSource: "github-bot" }); + const { stub } = await initNamedSession(sessionName, { + spawnSource: "github-bot", + repositories: [ + { repoOwner: "acme", repoName: "web-app", repoId: REVIEWED_REPO_ID, baseBranch: "main" }, + { repoOwner: "acme", repoName: "shared-config", repoId: 67890, baseBranch: "main" }, + ], + }); await seedSandboxAuth(stub, { authToken: "sandbox-token", sandboxId: "sandbox-1" }); await registerReviewFence(sessionName); @@ -158,10 +177,12 @@ describe("reviewer app token broker", () => { it("mints for a review session the GitHub bot created through the session API", async () => { const suffix = `created-${Date.now()}`; + // The claim below fences repository 1, so that is the scope the broker mints for. await cacheInstallationToken( `reviewer-${suffix}`, `ri-${suffix}`, - "reviewer-installation-token" + "reviewer-installation-token", + 1 ); const claim = await serviceFetch("https://test.local/internal/github-reviews/claim", { service: "github-bot", diff --git a/packages/control-plane/test/integration/helpers.ts b/packages/control-plane/test/integration/helpers.ts index ce02ee2037..d6d74e851a 100644 --- a/packages/control-plane/test/integration/helpers.ts +++ b/packages/control-plane/test/integration/helpers.ts @@ -267,7 +267,14 @@ export async function initSession(overrides?: { model: defaults.model ?? "anthropic/claude-haiku-4-5", reasoningEffort: defaults.reasoningEffort ?? null, baseBranch: defaults.defaultBranch ?? "main", - repositories: defaults.repositories, + repositories: defaults.repositories ?? [ + { + repoOwner: defaults.repoOwner, + repoName: defaults.repoName, + repoId: defaults.repoId, + baseBranch: defaults.defaultBranch ?? "main", + }, + ], environmentId: defaults.environmentId ?? null, status: "created", userId: defaults.userId, @@ -424,6 +431,18 @@ export async function initNamedSession( model: defaults.model ?? "anthropic/claude-haiku-4-5", reasoningEffort: defaults.reasoningEffort ?? null, baseBranch: defaults.defaultBranch ?? "main", + repositories: + defaults.repositories ?? + (defaults.repoOwner && defaults.repoName && defaults.repoId !== null + ? [ + { + repoOwner: defaults.repoOwner, + repoName: defaults.repoName, + repoId: defaults.repoId, + baseBranch: defaults.defaultBranch ?? "main", + }, + ] + : []), status: "created", parentSessionId: defaults.parentSessionId ?? null, spawnSource: defaults.spawnSource ?? "user", diff --git a/packages/control-plane/test/integration/hono-route-catalog-conformance.test.ts b/packages/control-plane/test/integration/hono-route-catalog-conformance.test.ts index ee47866570..e7d312bbd1 100644 --- a/packages/control-plane/test/integration/hono-route-catalog-conformance.test.ts +++ b/packages/control-plane/test/integration/hono-route-catalog-conformance.test.ts @@ -45,7 +45,7 @@ describe("Hono route catalog conformance", () => { }; }); - expect(manifest).toHaveLength(216); + expect(manifest).toHaveLength(220); // One compact, reviewable line per frozen route keeps the fixture explicit // without thousands of snapshot-only formatting lines. expect(manifest.map((entry) => JSON.stringify(entry))).toMatchSnapshot(); diff --git a/packages/control-plane/test/integration/modal-backend-builds.test.ts b/packages/control-plane/test/integration/modal-backend-builds.test.ts index 8523064b92..f1d3267b72 100644 --- a/packages/control-plane/test/integration/modal-backend-builds.test.ts +++ b/packages/control-plane/test/integration/modal-backend-builds.test.ts @@ -58,7 +58,7 @@ describe("Modal backend images over real D1", () => { const factory = { create: () => new ModalImageBuildAdapter( - new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm") + new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm", "github") ), }; const planner: ImageBuildPlannerPort = { diff --git a/packages/control-plane/test/integration/ownership-test-helpers.ts b/packages/control-plane/test/integration/ownership-test-helpers.ts new file mode 100644 index 0000000000..508e726812 --- /dev/null +++ b/packages/control-plane/test/integration/ownership-test-helpers.ts @@ -0,0 +1,99 @@ +import { createExecutionContext, env } from "cloudflare:test"; +import { expect } from "vitest"; +import type { PermissionId } from "@open-inspect/shared/rbac"; +import type { TeamRole } from "@open-inspect/shared/types/teams"; +import { EnvironmentStore, type EnvironmentRepositoryInsert } from "../../src/db/environments"; +import { TeamMembershipStore } from "../../src/db/team-memberships"; +import { + routeRequest, + serviceRequestHeaders, + sqlDatabase, + type ServiceRequestInit, +} from "./helpers"; + +export async function expectStatus(response: Promise, status: number) { + expect((await response).status).toBe(status); +} + +// Direct dispatch keeps SCM boundary spies in the same module context as the route. +export async function ownershipRequest(path: string, init: ServiceRequestInit = {}) { + const url = `https://test.local${path}`; + return routeRequest( + new Request(url, { + method: init.method ?? "GET", + body: init.body, + headers: await serviceRequestHeaders(url, init), + }), + env, + createExecutionContext() + ); +} + +export async function seedTeam(id: string, members: Array<[string, TeamRole]> = []) { + await env.DB.prepare( + "INSERT INTO teams (id, slug, name, default_visibility, created_at, updated_at) VALUES (?, ?, ?, 'team', 1, 1)" + ) + .bind(id, id, id) + .run(); + for (const [userId, role] of members) { + await new TeamMembershipStore(env.DB).add(id, userId, role); + } + return id; +} + +export async function seedEnvironment( + id: string, + ownerTeamId: string | null, + repositories: EnvironmentRepositoryInsert[] = [], + overrides: { name?: string; prebuild_enabled?: number } = {} +) { + await sqlDatabase(env.DB).batch([ + env.DB.prepare( + "INSERT INTO environments (id, name, owner_team_id, prebuild_enabled, created_at, updated_at) VALUES (?, ?, ?, ?, 1, 1)" + ).bind(id, overrides.name ?? id, ownerTeamId, overrides.prebuild_enabled ?? 0), + ...new EnvironmentStore(env.DB).bindRepositoryInserts(id, repositories), + ]); + return id; +} + +export async function seedGrant( + teamId: string, + repository: + | "installation" + | Pick +) { + const repo = repository === "installation" ? null : repository; + await env.DB.prepare( + `INSERT INTO team_repository_grants + (id, team_id, grant_kind, repo_external_id, repo_owner, repo_name, created_at) + VALUES (?, ?, ?, ?, ?, ?, 1)` + ) + .bind( + crypto.randomUUID(), + teamId, + repo ? "repository" : "installation", + repo?.repo_id ?? null, + repo?.repo_owner ?? null, + repo?.repo_name ?? null + ) + .run(); +} + +export async function assignCustomRole(userId: string, permissions: readonly PermissionId[]) { + const roleId = `role_custom_${userId}`; + await env.DB.batch([ + env.DB.prepare( + "INSERT INTO roles (id, name, normalized_name, is_system) VALUES (?, ?, ?, 0)" + ).bind(roleId, `Custom ${userId}`, `custom ${userId}`), + ...permissions.map((permission) => + env.DB.prepare("INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)").bind( + roleId, + permission + ) + ), + env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?").bind( + roleId, + userId + ), + ]); +} diff --git a/packages/control-plane/test/integration/response-compatibility.test.ts b/packages/control-plane/test/integration/response-compatibility.test.ts index c752f6c5d3..c22ceb06e2 100644 --- a/packages/control-plane/test/integration/response-compatibility.test.ts +++ b/packages/control-plane/test/integration/response-compatibility.test.ts @@ -192,9 +192,14 @@ describe("ordinary HTTP response compatibility", () => { it("maps a handler HttpError to JSON while retaining common headers", async () => { const traceId = "http-error-trace"; + await env.DB.prepare( + `INSERT INTO environments + (id, name, prebuild_enabled, created_at, updated_at) + VALUES ('env_empty_compat', 'Empty compatibility environment', 0, 1, 1)` + ).run(); const body = JSON.stringify({ - environmentId: `missing-${crypto.randomUUID()}`, - title: "Missing environment compatibility request", + environmentId: "env_empty_compat", + title: "Empty environment compatibility request", model: "anthropic/claude-haiku-4-5", }); const request = await signedRequest({ @@ -208,9 +213,9 @@ describe("ordinary HTTP response compatibility", () => { const response = await fetchWorker(request); - expect(response.status).toBe(404); + expect(response.status).toBe(500); await expect(response.json()).resolves.toEqual({ - error: expect.stringMatching(/^Environment not found: missing-/), + error: "Environment has no repositories: env_empty_compat", }); expectCommonResponseHeaders(response, traceId); }); diff --git a/packages/control-plane/test/integration/route-admission-matrix.test.ts b/packages/control-plane/test/integration/route-admission-matrix.test.ts index 58ad6f2a9b..f4f590791c 100644 --- a/packages/control-plane/test/integration/route-admission-matrix.test.ts +++ b/packages/control-plane/test/integration/route-admission-matrix.test.ts @@ -17,6 +17,7 @@ import { createControlPlaneApp } from "../../src/routing/hono-app"; import { listRouteContracts, type RouteContract } from "../../src/routing/route-contracts"; import { createCloudflareEnv } from "../../src/cloudflare/platform"; import { AutomationStore, type AutomationRow } from "../../src/db/automation-store"; +import { EnvironmentStore } from "../../src/db/environments"; import { TeamStore } from "../../src/db/teams"; import { TeamMembershipStore } from "../../src/db/team-memberships"; import { SessionCollaboratorStore } from "../../src/db/session-collaborators"; @@ -54,6 +55,7 @@ interface MatrixFixtures { sandboxSessionId: string; automationId: string; teamId: string; + environmentId: string; } function automation(id: string, userId: string): AutomationRow { @@ -108,6 +110,12 @@ function isTeamRoute(route: RouteContract): boolean { return route.path.startsWith("/teams/:id"); } +function environmentRequirementFor(route: RouteContract) { + return route.authorization.kind === "active-user" + ? route.authorization.allOf.find((requirement) => requirement.kind === "environment") + : undefined; +} + let automationSequence = 0; async function createAutomation(): Promise { const id = `matrix-automation-${automationSequence++}`; @@ -139,6 +147,7 @@ describe("route admission matrix", { timeout: MATRIX_TIMEOUT_MS }, () => { sandboxSessionId: "", automationId: "", teamId: "", + environmentId: "", }; beforeAll(async () => { @@ -406,6 +415,7 @@ describe("route admission sentinel", { timeout: MATRIX_TIMEOUT_MS }, () => { sandboxSessionId: "", automationId: "", teamId: "", + environmentId: "env_sentinel", }; // Every production contract, admitted by its own policy, in front of a // sentinel handler. @@ -437,6 +447,19 @@ describe("route admission sentinel", { timeout: MATRIX_TIMEOUT_MS }, () => { joinPolicy: "open", }) ).id; + await new EnvironmentStore(env.DB).create( + { + id: fixtures.environmentId, + owner_team_id: null, + name: "Sentinel environment", + description: null, + prebuild_enabled: 0, + channel_associations: null, + created_at: 1, + updated_at: 1, + }, + [] + ); for (const [userId, role] of [ [OTHER_MEMBER, "member"], [TEAM_VIEWER, "viewer"], @@ -500,11 +523,16 @@ describe("route admission sentinel", { timeout: MATRIX_TIMEOUT_MS }, () => { ? fixtures.sandboxSessionId : fixtures.readonlySessionId; const url = `${BASE}${materialize(route, { + ...(environmentRequirementFor(route)?.kind === "environment" + ? { [environmentRequirementFor(route)!.idParam]: fixtures.environmentId } + : {}), id: isTeamRoute(route) ? fixtures.teamId : isAutomationRoute(route) ? fixtures.automationId - : sessionId, + : environmentRequirementFor(route)?.idParam === "id" + ? fixtures.environmentId + : sessionId, childId: fixtures.sandboxSessionId, })}`; const method = route.method; diff --git a/packages/control-plane/test/integration/sandbox-state-retention.test.ts b/packages/control-plane/test/integration/sandbox-state-retention.test.ts index 05e6d2411b..968cc8effd 100644 --- a/packages/control-plane/test/integration/sandbox-state-retention.test.ts +++ b/packages/control-plane/test/integration/sandbox-state-retention.test.ts @@ -46,7 +46,7 @@ function snapshotProvider(overrides: Partial = {}): SandboxProv } describe("sandbox state retention", () => { - it("reconstructs and rearms persisted rejected cleanup without releasing a durable shutdown hold", async () => { + it("reconstructs and delivers persisted rejected cleanup without releasing a durable shutdown hold", async () => { const stub = await servingSession(); await runInSessionDO(stub, async (instance, durableState) => { const background: Promise[] = []; @@ -82,11 +82,29 @@ describe("sandbox state retention", () => { }, }); const held = shutdownStore.read(); + const now = vi.spyOn(Date, "now"); durableState.storage.sql.exec("DELETE FROM session_alarm_state"); await durableState.storage.deleteAlarm(); + let canStop = false; + const stopObservations: Array<{ + pending_deadline: unknown; + in_flight_deadline: unknown; + hold: unknown; + }> = []; const stop = vi .spyOn(ModalSandboxProvider.prototype, "stopSandbox") - .mockRejectedValue(new Error("provider unavailable")); + .mockImplementation(async () => { + const [delivery] = durableState.storage.sql + .exec("SELECT pending_deadline, in_flight_deadline FROM session_alarm_state") + .toArray(); + stopObservations.push({ + pending_deadline: delivery.pending_deadline, + in_flight_deadline: delivery.in_flight_deadline, + hold: shutdownStore.read(), + }); + if (!canStop) throw new Error("provider unavailable"); + return { success: true }; + }); try { const restarted = createSessionRuntime(platform, runtimeEnv); const manager = restarted.internals.lifecycleManager; @@ -98,10 +116,15 @@ describe("sandbox state retention", () => { .toArray(); expect(deadline.pending_deadline).toBeGreaterThanOrEqual(beforeRearm + 30_000); expect(deadline.pending_deadline).toBeLessThanOrEqual(Date.now() + 30_000); + expect(await durableState.storage.getAlarm()).toBe(deadline.pending_deadline); expect(stop).not.toHaveBeenCalled(); expect(manager.mayProcessQueuedWork()).toBe(false); - expect(await manager.handleShutdownAlarm()).toBe("hold_watchdogs"); - expect(stop).toHaveBeenCalledOnce(); + now.mockReturnValue(Number(deadline.pending_deadline)); + // Simulate the host consuming its alarm before delivering the due callback. + await durableState.storage.deleteAlarm(); + await restarted.server.onScheduledDeadline(); + // Both shutdown-priority passes retry cleanup, even while ordinary watchdogs are held. + expect(stop).toHaveBeenCalledTimes(2); expect(restarted.internals.sandboxRepository.getSandbox()).toMatchObject({ modal_object_id: "rejected-source", startup_rejected: 1, @@ -110,8 +133,20 @@ describe("sandbox state retention", () => { active_socket_id: "", }); expect(shutdownStore.read()).toEqual(held); - stop.mockResolvedValue({ success: true }); - expect(await manager.handleShutdownAlarm()).toBe("hold_watchdogs"); + const [retry] = durableState.storage.sql + .exec("SELECT pending_deadline FROM session_alarm_state") + .toArray(); + expect(retry.pending_deadline).toBe(Date.now() + 30_000); + expect(await durableState.storage.getAlarm()).toBe(retry.pending_deadline); + expect( + durableState.storage.sql + .exec("SELECT in_flight_deadline FROM session_alarm_state") + .toArray() + ).toEqual([{ in_flight_deadline: null }]); + canStop = true; + now.mockReturnValue(Number(retry.pending_deadline)); + await durableState.storage.deleteAlarm(); + await restarted.server.onScheduledDeadline(); expect(restarted.internals.sandboxRepository.getSandbox()).toMatchObject({ modal_object_id: null, startup_rejected: 1, @@ -119,10 +154,43 @@ describe("sandbox state retention", () => { status: "failed", }); expect(shutdownStore.read()).toEqual(held); - expect(await manager.handleShutdownAlarm()).toBe("hold_watchdogs"); - expect(stop).toHaveBeenCalledTimes(2); + // Successful stop leaves its pre-I/O retry armed; the final due wake drains it without stopping again. + const [lastRetry] = durableState.storage.sql + .exec("SELECT pending_deadline FROM session_alarm_state") + .toArray(); + expect(lastRetry.pending_deadline).toBe(Date.now() + 30_000); + expect(await durableState.storage.getAlarm()).toBe(lastRetry.pending_deadline); + now.mockReturnValue(Number(lastRetry.pending_deadline)); + await durableState.storage.deleteAlarm(); + await restarted.server.onScheduledDeadline(); + expect(stop).toHaveBeenCalledTimes(3); expect(shutdownStore.read()).toEqual(held); + expect(await durableState.storage.getAlarm()).toBeNull(); + expect( + durableState.storage.sql + .exec("SELECT pending_deadline, in_flight_deadline FROM session_alarm_state") + .toArray() + ).toEqual([{ pending_deadline: null, in_flight_deadline: null }]); + for (const [config] of stop.mock.calls) { + expect(config).toMatchObject({ + providerObjectId: "rejected-source", + intent: "destroy", + reason: "startup_superseded", + generationCreatedAtMs: undefined, + }); + expect(config.signal).toBeInstanceOf(AbortSignal); + } + // Assert outside the provider stub: cleanup intentionally catches provider exceptions. + expect(stopObservations).toHaveLength(3); + for (const observation of stopObservations) { + expect(observation.in_flight_deadline).toEqual(expect.any(Number)); + expect(observation.pending_deadline).toBe( + Number(observation.in_flight_deadline) + 30_000 + ); + expect(observation.hold).toEqual(held); + } } finally { + now.mockRestore(); stop.mockRestore(); } }); diff --git a/packages/control-plane/test/integration/sandbox-vm-reconciliation.test.ts b/packages/control-plane/test/integration/sandbox-vm-reconciliation.test.ts new file mode 100644 index 0000000000..700d83863a --- /dev/null +++ b/packages/control-plane/test/integration/sandbox-vm-reconciliation.test.ts @@ -0,0 +1,382 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { env } from "cloudflare:test"; +import { hashToken } from "../../src/auth/crypto"; +import type { SessionDO } from "../../src/cloudflare/durable-object"; +import { createCloudflareEnv, type WorkerBindings } from "../../src/cloudflare/platform"; +import { createDurableObjectSessionPlatform } from "../../src/cloudflare/session-platform"; +import type { BackgroundTasks, SessionWebSocket } from "../../src/platform-ports"; +import { ModalVmStartupError } from "../../src/sandbox/client"; +import type { ResolveSandboxResult } from "../../src/sandbox/provider"; +import { ModalSandboxProvider } from "../../src/sandbox/providers/modal-provider"; +import { formatPendingVmReference } from "../../src/sandbox/providers/pending-vm-reference"; +import { createSessionRuntime } from "../../src/session/components"; +import { SandboxShutdownRepository } from "../../src/session/sandbox-shutdown-repository"; +import { cleanD1Tables } from "./cleanup"; +import { initNamedSession, queryDO, seedSandboxAuth } from "./helpers"; +import { runInSessionDO } from "./session-do-access"; + +const SANDBOX_TIMEOUT_MS = 3_600_000; +const FINAL_SNAPSHOT_BUFFER_MS = 600_000; + +beforeEach(cleanD1Tables); +afterEach(async () => { + vi.restoreAllMocks(); + await cleanD1Tables(); +}); + +async function pendingVmSession() { + const { stub } = await initNamedSession(`vm-reconciliation-${crypto.randomUUID()}`, { + sandboxSettings: { + sandboxTimeoutMs: SANDBOX_TIMEOUT_MS, + finalSnapshotBufferMs: FINAL_SNAPSHOT_BUFFER_MS, + terminalEnabled: true, + }, + }); + await seedSandboxAuth(stub, { + authToken: "predecessor-token", + sandboxId: "predecessor-sandbox", + status: "pending", + }); + await queryDO(stub, "UPDATE sandbox SET created_at = 0"); + return stub; +} + +/** Leave a real foreground create pending, then rebuild the graph over its persisted reservation. */ +async function reconstructedPendingVm(instance: SessionDO, state: DurableObjectState) { + const platform = createDurableObjectSessionPlatform(state, env.DB); + const tasks: Array<{ + factory: Parameters[0]; + name: string; + promise: Promise; + }> = []; + platform.createBackgroundTasks = () => ({ + submit: (factory, metadata) => { + tasks.push({ factory, name: metadata.name, promise: factory() }); + }, + }); + + const adopted = new Map(); + vi.spyOn(platform.sockets, "adopt").mockImplementation((socket, tags) => { + adopted.set(socket, tags); + }); + vi.spyOn(platform.sockets, "tags").mockImplementation((socket) => adopted.get(socket) ?? []); + vi.spyOn(platform.sockets, "sockets").mockImplementation((tag) => + [...adopted].filter(([, tags]) => !tag || tags.includes(tag)).map(([socket]) => socket) + ); + const browser = { readyState: 1, send: vi.fn(), close: vi.fn() } satisfies SessionWebSocket; + const bridge = { readyState: 1, send: vi.fn(), close: vi.fn() } satisfies SessionWebSocket; + + let finishCreate!: () => void; + const createGate = new Promise((resolve) => (finishCreate = resolve)); + const create = vi + .spyOn(ModalSandboxProvider.prototype, "createSandbox") + .mockImplementation(async () => { + await createGate; + throw new ModalVmStartupError("unknown", new Error("Original create response was lost")); + }); + let finishLookup!: (result: ResolveSandboxResult) => void; + const lookup = new Promise((resolve) => (finishLookup = resolve)); + const resolve = vi + .spyOn(ModalSandboxProvider.prototype, "resolveSandbox") + .mockReturnValue(lookup); + const stop = vi.spyOn(ModalSandboxProvider.prototype, "stopSandbox").mockResolvedValue({ + success: true, + }); + + const runtimeEnv = createCloudflareEnv((instance as unknown as { env: WorkerBindings }).env); + runtimeEnv.SANDBOX_PROVIDER = "modal-vm"; + const initial = createSessionRuntime(platform, runtimeEnv); + const spawning = initial.internals.lifecycleManager.spawnSandbox(); + await vi.waitFor(() => expect(create).toHaveBeenCalledOnce()); + const config = create.mock.calls[0][0]; + const row = initial.internals.sandboxRepository.getSandbox()!; + const generation = { sandboxId: row.modal_sandbox_id!, createdAt: row.created_at }; + const reference = formatPendingVmReference(config.sessionId, generation.sandboxId); + expect(row).toMatchObject({ status: "spawning", modal_object_id: reference, auth_token: null }); + + const restarted = createSessionRuntime(platform, runtimeEnv); + const { wsManager } = restarted.internals; + wsManager.acceptClientSocket(browser, "observing-browser"); + wsManager.setClient(browser, { + participantId: "observing-participant", + userId: "user-1", + name: "Observer", + status: "active", + lastSeen: Date.now(), + clientId: "observing-browser", + authorizationExpiresAt: Date.now() + SANDBOX_TIMEOUT_MS, + }); + wsManager.acceptAndSetSandboxSocket(bridge, generation.sandboxId); + + const result: ResolveSandboxResult = { + sandboxId: generation.sandboxId, + providerObjectId: "resolved-vm", + // A lookup must not replace the reservation's conservative lifetime with this later one. + lifetime: { + kind: "finite", + expiresAtMs: generation.createdAt + SANDBOX_TIMEOUT_MS * 2, + observedAtMs: Date.now(), + source: "provider", + }, + codeServerUrl: "https://editor.test", + codeServerPassword: "editor-secret", + vncAccess: { url: "https://desktop.test", password: "desktop-secret" }, + ttydUrl: "https://terminal.test", + tunnelUrls: { "8080": "https://port.test" }, + }; + return { + platform, + runtimeEnv, + initial, + restarted, + browser, + bridge, + create, + resolve, + stop, + config, + generation, + reference, + result, + finishLookup, + shutdown: new SandboxShutdownRepository(state.storage.sql), + vmResolution() { + const resolutions = tasks.filter((task) => task.name === "sandbox.vm_resolve"); + expect(resolutions).toHaveLength(1); + return resolutions[0]; + }, + async finish() { + finishLookup(result); + finishCreate(); + await spawning; + for (const task of tasks) await task.promise; + }, + }; +} + +async function refuseEncryptedLookup(change: "reference" | "timestamp") { + const stub = await pendingVmSession(); + await runInSessionDO(stub, async (instance, state) => { + const f = await reconstructedPendingVm(instance, state); + const { lifecycleManager: manager, sandboxRepository: sandbox } = f.restarted.internals; + const realEncrypt = crypto.subtle.encrypt.bind(crypto.subtle); + let releaseEncryption!: () => void; + const encryptionGate = new Promise((resolve) => (releaseEncryption = resolve)); + let encryptedBothSecrets!: () => void; + const encrypted = new Promise((resolve) => (encryptedBothSecrets = resolve)); + let encryptionCount = 0; + const encrypt = vi.spyOn(crypto.subtle, "encrypt").mockImplementation(async (...args) => { + const ciphertext = await realEncrypt(...args); + if (++encryptionCount === 2) encryptedBothSecrets(); + await encryptionGate; + return ciphertext; + }); + try { + manager.onSandboxConnected(); + manager.onSandboxSocketAttached(f.generation); + expect(manager.onRuntimeReady(Date.now(), "opencode", 1)).toBe(true); + const resolution = f.vmResolution(); + f.finishLookup(f.result); + await encrypted; + expect(encrypt).toHaveBeenCalledTimes(2); + expect(sandbox.getSandbox()).toMatchObject({ + status: "ready", + modal_object_id: f.reference, + code_server_password: null, + vnc_password: null, + }); + + // Change each SQL predicate independently while real ciphertext is waiting to return. + // A different pending reference is not a bridge-resolved handle the foreground may reconcile. + if (change === "reference") + sandbox.updateSandboxModalObjectId( + formatPendingVmReference("other-session", f.generation.sandboxId) + ); + else state.storage.sql.exec("UPDATE sandbox SET created_at = created_at + 1"); + const successor = sandbox.getSandbox(); + const shutdownBefore = f.shutdown.read(); + f.browser.send.mockClear(); + f.bridge.send.mockClear(); + releaseEncryption(); + await resolution.promise; + + expect(sandbox.getSandbox()).toEqual(successor); + expect(f.shutdown.read()).toEqual(shutdownBefore); + expect(f.shutdown.read()?.providerObjectId).toBe(f.reference); + expect(f.browser.send).not.toHaveBeenCalled(); + expect(f.bridge.send).not.toHaveBeenCalled(); + expect(f.stop).not.toHaveBeenCalled(); + expect(f.create).toHaveBeenCalledOnce(); + expect(f.resolve).toHaveBeenCalledExactlyOnceWith({ + sessionId: f.config.sessionId, + sandboxId: f.generation.sandboxId, + generationCreatedAtMs: f.generation.createdAt, + timeoutSeconds: SANDBOX_TIMEOUT_MS / 1000, + }); + await f.finish(); + expect(sandbox.getSandbox()).toEqual(successor); + expect(f.shutdown.read()).toEqual(shutdownBefore); + expect(f.browser.send).not.toHaveBeenCalled(); + expect(f.bridge.send).not.toHaveBeenCalled(); + expect(f.resolve).toHaveBeenCalledOnce(); + expect(f.stop).not.toHaveBeenCalled(); + } finally { + releaseEncryption(); + await f.finish(); + encrypt.mockRestore(); + } + }); +} + +describe("production-wired VM startup reconciliation", () => { + it.each(["reference", "timestamp"] as const)( + "refuses a lookup after only its expected %s changes during real encryption", + refuseEncryptedLookup + ); + + it("reconstructs pending lookup with encrypted access, early readiness and independent admission gates", async () => { + const stub = await pendingVmSession(); + await runInSessionDO(stub, async (instance, state) => { + const f = await reconstructedPendingVm(instance, state); + try { + const { + lifecycleManager: manager, + sandboxRepository: sandbox, + wsManager, + } = f.restarted.internals; + expect(sandbox.getSandbox()?.auth_token_hash).toBe( + await hashToken(f.config.sandboxAuthToken) + ); + expect(f.initial.internals.lifecycleManager.isProviderStartupPending()).toBe(true); + expect(manager.isProviderStartupPending()).toBe(false); + manager.onSandboxConnected(); + manager.onSandboxSocketAttached(f.generation); + expect(sandbox.getSandbox()?.status).toBe("connecting"); + expect(wsManager.getSandboxCommandTarget()).toEqual({ kind: "booting", phase: null }); + expect(manager.mayProcessQueuedWork()).toBe(false); + expect(manager.pushAdmissionDecision()).toBe("held"); + expect(f.shutdown.read()).toMatchObject({ + phase: "running", + provider: "modal-vm", + providerObjectId: f.reference, + lifetimeKind: "finite", + lifetimeSource: "conservative_start_bound", + expiresAtMs: f.generation.createdAt + SANDBOX_TIMEOUT_MS, + drainAtMs: f.generation.createdAt + SANDBOX_TIMEOUT_MS - FINAL_SNAPSHOT_BUFFER_MS, + generationReady: false, + lifecyclePolicy: "confirmed", + }); + + expect(manager.onRuntimeReady(Date.now(), "opencode", 1)).toBe(true); + expect(sandbox.getSandbox()).toMatchObject({ + status: "ready", + modal_object_id: f.reference, + }); + expect(wsManager.getSandboxCommandTarget()).toEqual({ kind: "dispatch", socket: f.bridge }); + expect(f.bridge.send).toHaveBeenCalledWith( + JSON.stringify({ type: "sandbox_generation", generation: f.generation }) + ); + expect(manager.mayProcessQueuedWork()).toBe(false); + expect(manager.pushAdmissionDecision()).toBe("held"); + const readyWithoutAck = f.shutdown.read(); + manager.onShutdownGenerationReady({ + type: "sandbox_generation_ready", + sandboxId: f.generation.sandboxId, + generation: { ...f.generation, createdAt: f.generation.createdAt + 1 }, + timestamp: Date.now(), + }); + expect(f.shutdown.read()).toEqual(readyWithoutAck); + manager.onShutdownGenerationReady({ + type: "sandbox_generation_ready", + sandboxId: f.generation.sandboxId, + generation: f.generation, + timestamp: Date.now(), + }); + + // Confirmed pending lifetime plus acknowledgement admits work before lookup completes. + expect(manager.mayProcessQueuedWork()).toBe(true); + expect(manager.pushAdmissionDecision()).toBe("ready"); + expect(f.initial.internals.lifecycleManager.mayProcessQueuedWork()).toBe(false); + expect(f.initial.internals.lifecycleManager.pushAdmissionDecision()).toBe("start_required"); + const acknowledged = f.shutdown.read()!; + f.shutdown.write({ + ...acknowledged, + lifetimeKind: "unknown", + expiresAtMs: null, + drainAtMs: null, + }); + expect(manager.mayProcessQueuedWork()).toBe(false); + expect(manager.pushAdmissionDecision()).toBe("held"); + f.shutdown.write(acknowledged); + expect(manager.mayProcessQueuedWork()).toBe(true); + expect(sandbox.getSandbox()).toMatchObject({ + modal_object_id: f.reference, + code_server_url: null, + vnc_url: null, + ttyd_token: null, + }); + const resolution = f.vmResolution(); + expect(resolution.factory).toEqual(expect.any(Function)); + expect(f.resolve).toHaveBeenCalledExactlyOnceWith({ + sessionId: f.config.sessionId, + sandboxId: f.generation.sandboxId, + generationCreatedAtMs: f.generation.createdAt, + timeoutSeconds: SANDBOX_TIMEOUT_MS / 1000, + }); + f.browser.send.mockClear(); + f.finishLookup(f.result); + await resolution.promise; + + expect(sandbox.getSandbox()).toMatchObject({ + status: "ready", + modal_object_id: "resolved-vm", + code_server_url: f.result.codeServerUrl, + code_server_password: expect.any(String), + vnc_url: f.result.vncAccess!.url, + vnc_password: expect.any(String), + ttyd_url: null, + ttyd_token: null, + tunnel_urls: JSON.stringify(f.result.tunnelUrls), + }); + expect(sandbox.getSandbox()?.code_server_password).not.toBe(f.result.codeServerPassword); + expect(sandbox.getSandbox()?.vnc_password).not.toBe(f.result.vncAccess!.password); + const reread = createSessionRuntime(f.platform, f.runtimeEnv).internals.sandboxRepository; + expect(await reread.getSandboxAccessSecret("codeServer")).toBe(f.result.codeServerPassword); + expect(await reread.getSandboxAccessSecret("vnc")).toBe(f.result.vncAccess!.password); + expect(await reread.getSandboxAccessSecret("ttyd")).toBeNull(); + expect(f.shutdown.read()).toEqual({ ...acknowledged, providerObjectId: "resolved-vm" }); + expect(f.browser.send).toHaveBeenCalledWith( + JSON.stringify({ type: "sandbox_access_changed" }) + ); + expect(manager.mayProcessQueuedWork()).toBe(true); + expect(manager.pushAdmissionDecision()).toBe("ready"); + expect(f.create).toHaveBeenCalledOnce(); + expect(f.stop).not.toHaveBeenCalled(); + // The still-live foreground instance retains signing authority, unlike the reconstructed one. + await f.finish(); + expect(f.resolve).toHaveBeenCalledTimes(2); + expect(f.resolve.mock.calls[1][0]).toMatchObject(f.resolve.mock.calls[0][0]); + expect(sandbox.getSandbox()).toMatchObject({ + status: "ready", + modal_sandbox_id: f.generation.sandboxId, + created_at: f.generation.createdAt, + modal_object_id: "resolved-vm", + ttyd_url: f.result.ttydUrl, + last_spawn_error: null, + }); + expect(await sandbox.getSandboxAccessSecret("codeServer")).toBe( + f.result.codeServerPassword + ); + expect(await sandbox.getSandboxAccessSecret("vnc")).toBe(f.result.vncAccess!.password); + expect(await sandbox.getSandboxAccessSecret("ttyd")).toEqual(expect.any(String)); + expect(f.shutdown.read()).toEqual({ ...acknowledged, providerObjectId: "resolved-vm" }); + expect(f.initial.internals.lifecycleManager.isProviderStartupPending()).toBe(false); + expect(f.initial.internals.lifecycleManager.mayProcessQueuedWork()).toBe(true); + expect(f.create).toHaveBeenCalledOnce(); + expect(f.stop).not.toHaveBeenCalled(); + } finally { + await f.finish(); + } + }); + }); +}); diff --git a/packages/control-plane/test/integration/scheduler-slack-team-steering.test.ts b/packages/control-plane/test/integration/scheduler-slack-team-steering.test.ts new file mode 100644 index 0000000000..6a656aac47 --- /dev/null +++ b/packages/control-plane/test/integration/scheduler-slack-team-steering.test.ts @@ -0,0 +1,294 @@ +import { env } from "cloudflare:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BUILT_IN_ROLE_REGISTRY } from "@open-inspect/shared/rbac"; +import type { SessionVisibility } from "@open-inspect/shared/types/teams"; +import type { SlackAutomationEvent } from "@open-inspect/shared/triggers"; +import { AuthorizationService } from "../../src/authorization/service"; +import { createCloudflareEnv } from "../../src/cloudflare/platform"; +import { AutomationStore } from "../../src/db/automation-store"; +import { SessionCollaboratorStore } from "../../src/db/session-collaborators"; +import { SessionIndexStore } from "../../src/db/session-index"; +import { SlackChannelStore } from "../../src/db/slack-channel-store"; +import { TeamMembershipStore } from "../../src/db/team-memberships"; +import { Scheduler } from "../../src/scheduler/scheduler"; +import { cleanD1Tables } from "./cleanup"; +import { seedActiveUser } from "./helpers"; +import { fetchRuns, makeRunRow, seedRun } from "./run-helpers"; +import { assignCustomRole, seedTeam } from "./ownership-test-helpers"; + +const SESSION_OWNER = "11111111111111111111111111111111"; +const MEMBER = "22222222222222222222222222222222"; +const WORKSPACE_OWNER = "55555555555555555555555555555555"; +const SESSION_TEAM = "team_slack_session"; +const AUTOMATION_TEAM = "team_slack_automation"; +const THREAD_KEY = "slack:C1:steering-root"; + +function slackEvent(actor: string): SlackAutomationEvent { + const ts = `${Date.now()}.${Math.floor(Math.random() * 1e6)}`; + return { + source: "slack", + eventType: "message.posted", + triggerKey: `slack:msg:C1:${ts}`, + concurrencyKey: THREAD_KEY, + contextBlock: "Slack steering fixture", + meta: {}, + channelId: "C1", + threadTs: "steering-root", + ts, + actorUserId: `U-${actor}`, + // Also match the launch condition: a denied steer must not create a replacement run. + text: "deploy and also update the changelog", + }; +} + +function createSteeringScheduler(mode: "off" | "shadow" | "on" = "shadow") { + const requests = vi.fn(async (request: Request, _sessionId: string) => { + expect(new URL(request.url).pathname).toBe("/internal/prompt"); + return Response.json({ messageId: "msg-steering", status: "queued" }); + }); + const schedulerEnv = createCloudflareEnv({ ...env, TEAMS_ENFORCEMENT: mode }); + schedulerEnv.SESSION = (sessionId, request) => requests(request, sessionId); + return { scheduler: new Scheduler(env.DB, schedulerEnv, { submit() {} }), requests }; +} + +async function seedSteerableSession( + sessionId = "session-steering", + ownerTeamId: string | null = SESSION_TEAM, + visibility: SessionVisibility = "private" +) { + const automationId = `auto-${sessionId}`; + const now = Date.now(); + await env.DB.prepare( + `INSERT INTO automations + (id, owner_team_id, name, instructions, trigger_type, model, created_by, user_id, + created_at, updated_at, event_type, trigger_config) + VALUES (?, ?, 'Slack steering', 'Fixture only', 'slack_event', 'anthropic/claude-sonnet-4-6', + ?, ?, ?, ?, 'message.posted', ?)` + ) + .bind( + automationId, + AUTOMATION_TEAM, + SESSION_OWNER, + SESSION_OWNER, + now, + now, + JSON.stringify({ + conditions: [ + { type: "slack_channel", operator: "any_of", value: ["C1"] }, + { type: "text_match", operator: "contains", value: { pattern: "deploy" } }, + ], + }) + ) + .run(); + await env.DB.prepare( + "INSERT INTO automation_slack_channels (automation_id, channel_id) VALUES (?, 'C1')" + ) + .bind(automationId) + .run(); + await env.DB.prepare( + `INSERT INTO sessions + (id, title, owner_team_id, visibility, user_id, status, automation_id, automation_run_id, + spawn_source, created_at, updated_at) + VALUES (?, 'Existing thread session', ?, ?, ?, 'completed', ?, ?, 'automation', ?, ?)` + ) + .bind( + sessionId, + ownerTeamId, + visibility, + SESSION_OWNER, + automationId, + `run-${sessionId}`, + now, + now + ) + .run(); + await seedRun( + makeRunRow(automationId, { + id: `run-${sessionId}`, + session_id: sessionId, + status: "completed", + completed_at: now, + }), + { concurrencyKey: THREAD_KEY } + ); + return { automationId, sessionId }; +} + +async function expectOriginalRunOnly(automationId: string) { + expect(await fetchRuns(automationId)).toEqual([expect.objectContaining({ status: "completed" })]); + const store = new AutomationStore(env.DB); + expect( + (await store.listInvocations(automationId, { limit: 20, offset: 0 })).invocations + ).toHaveLength(1); + expect(await store.getById(automationId)).toMatchObject({ enabled: 1, consecutive_failures: 0 }); +} + +describe("Scheduler Slack team steering (real D1)", () => { + beforeEach(async () => { + await cleanD1Tables(); + for (const userId of [SESSION_OWNER, MEMBER, WORKSPACE_OWNER]) { + await seedActiveUser(userId); + await env.DB.prepare( + `INSERT INTO user_identities (id, user_id, provider, provider_user_id, provider_issuer, created_at, updated_at) + VALUES (?, ?, 'slack', ?, 'https://slack.com', 1, 1)` + ) + .bind(`identity-${userId}`, userId, `U-${userId}`) + .run(); + } + await env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?") + .bind(BUILT_IN_ROLE_REGISTRY.owner.id, WORKSPACE_OWNER) + .run(); + await seedTeam(SESSION_TEAM, [ + [SESSION_OWNER, "member"], + [MEMBER, "member"], + [WORKSPACE_OWNER, "member"], + ]); + await seedTeam(AUTOMATION_TEAM, [[SESSION_OWNER, "member"]]); + }); + afterEach(async () => { + vi.restoreAllMocks(); + await cleanD1Tables(); + }); + + it.each(["team", "private"] as const)( + "requires sessions.read for %s steering even in off mode", + async (visibility) => { + const { automationId, sessionId } = await seedSteerableSession( + "session-steering", + SESSION_TEAM, + visibility + ); + await assignCustomRole(SESSION_OWNER, ["sessions.collaborate"]); + const sessionGet = vi.spyOn(SessionIndexStore.prototype, "get"); + const { scheduler, requests } = createSteeringScheduler("off"); + const result = await scheduler.event(slackEvent(SESSION_OWNER)); + expect(result).toEqual({ triggered: 0, skipped: 0, steered: 0 }); + expect(sessionGet).toHaveBeenCalledWith(sessionId); + expect(requests).not.toHaveBeenCalled(); + await expectOriginalRunOnly(automationId); + } + ); + + it.each(["off", "shadow", "on"] as const)( + "preserves workspace collaboration-only steering in %s mode", + async (mode) => { + const { automationId } = await seedSteerableSession("session-steering", null, "workspace"); + await assignCustomRole(MEMBER, ["sessions.collaborate"]); + const { scheduler, requests } = createSteeringScheduler(mode); + const result = await scheduler.event(slackEvent(MEMBER)); + expect(result).toEqual({ triggered: 0, skipped: 0, steered: mode === "on" ? 0 : 1 }); + expect(requests).toHaveBeenCalledTimes(mode === "on" ? 0 : 1); + await expectOriginalRunOnly(automationId); + } + ); + + it.each([SESSION_OWNER, MEMBER])( + "revalidates %s participation across archive and membership removal", + async (actor) => { + const { automationId, sessionId } = await seedSteerableSession(); + const collaborators = new SessionCollaboratorStore(env.DB); + if (actor === MEMBER) await collaborators.add(sessionId, actor, SESSION_OWNER); + const memberships = vi.spyOn(TeamMembershipStore.prototype, "listForUser"); + const { scheduler, requests } = createSteeringScheduler(); + const initial = await scheduler.event(slackEvent(actor)); + expect(initial).toEqual({ triggered: 0, skipped: 0, steered: 1 }); + await env.DB.prepare("UPDATE teams SET archived_at = 1 WHERE id IN (?, ?)") + .bind(SESSION_TEAM, AUTOMATION_TEAM) + .run(); + const archived = await scheduler.event(slackEvent(actor)); + expect(archived).toEqual({ triggered: 0, skipped: 0, steered: 1 }); + await env.DB.prepare("DELETE FROM team_memberships WHERE team_id = ? AND user_id = ?") + .bind(SESSION_TEAM, actor) + .run(); + const departed = await scheduler.event(slackEvent(actor)); + expect(departed).toEqual({ triggered: 0, skipped: 0, steered: 0 }); + expect(memberships).toHaveBeenCalledTimes(3); + expect(requests).toHaveBeenCalledTimes(2); + expect(await requests.mock.calls[0][0].json()).toMatchObject({ + source: "slack", + authorId: `slack:U-${actor}`, + canonicalUserId: actor, + }); + if (actor === MEMBER) expect(await collaborators.listUserIds(sessionId)).toEqual([actor]); + await expectOriginalRunOnly(automationId); + } + ); + + it.each(["suspended", "role revoked"])( + "revalidates actor authority after %s", + async (scenario) => { + const { automationId } = await seedSteerableSession(); + const authorization = vi.spyOn(AuthorizationService.prototype, "getEffectiveAuthorization"); + const sessionGet = vi.spyOn(SessionIndexStore.prototype, "get"); + const { scheduler, requests } = createSteeringScheduler(); + const initial = await scheduler.event(slackEvent(SESSION_OWNER)); + expect(initial).toEqual({ triggered: 0, skipped: 0, steered: 1 }); + sessionGet.mockClear(); + if (scenario === "suspended") { + await env.DB.prepare("UPDATE users SET suspended_at = 1 WHERE id = ?") + .bind(SESSION_OWNER) + .run(); + } else { + await env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?") + .bind(BUILT_IN_ROLE_REGISTRY.viewer.id, SESSION_OWNER) + .run(); + } + const revoked = await scheduler.event(slackEvent(SESSION_OWNER)); + expect(revoked).toEqual({ triggered: 0, skipped: 0, steered: 0 }); + expect(authorization).toHaveBeenCalledTimes(2); + expect(sessionGet).not.toHaveBeenCalled(); + expect(requests).toHaveBeenCalledTimes(1); + await expectOriginalRunOnly(automationId); + } + ); + + it("audits Owner break-glass without steering or replacement firing", async () => { + const { automationId, sessionId } = await seedSteerableSession(); + const { scheduler, requests } = createSteeringScheduler("off"); + const result = await scheduler.event(slackEvent(WORKSPACE_OWNER)); + expect(result).toEqual({ triggered: 0, skipped: 0, steered: 0 }); + expect(requests).not.toHaveBeenCalled(); + const audits = await env.DB.prepare( + "SELECT principal_kind, actor_user_id_snapshot, resource_id, team_id FROM authorization_audit_events WHERE action = 'session.private_break_glass'" + ).all(); + expect(audits.results).toEqual([ + { + principal_kind: "user", + actor_user_id_snapshot: WORKSPACE_OWNER, + resource_id: sessionId, + team_id: SESSION_TEAM, + }, + ]); + await expectOriginalRunOnly(automationId); + }); + + it("checks denied, allowed, and foreign-team candidates independently", async () => { + const targets = [ + await seedSteerableSession("1-denied"), + await seedSteerableSession("2-allowed"), + await seedSteerableSession("3-foreign", AUTOMATION_TEAM), + ]; + const collaborators = new SessionCollaboratorStore(env.DB); + await collaborators.add("2-allowed", MEMBER, SESSION_OWNER); + await collaborators.add("3-foreign", MEMBER, SESSION_OWNER); + // Order the real SQL candidates so one event exercises deny/allow/deny. + const getCandidates = SlackChannelStore.prototype.getSlackAutomationsForChannel; + vi.spyOn(SlackChannelStore.prototype, "getSlackAutomationsForChannel").mockImplementation( + async function (this: SlackChannelStore, channelId) { + return (await getCandidates.call(this, channelId)).sort((a, b) => a.id.localeCompare(b.id)); + } + ); + const authorization = vi.spyOn(AuthorizationService.prototype, "getEffectiveAuthorization"); + const memberships = vi.spyOn(TeamMembershipStore.prototype, "listForUser"); + const sessionGet = vi.spyOn(SessionIndexStore.prototype, "get"); + const { scheduler, requests } = createSteeringScheduler(); + const result = await scheduler.event(slackEvent(MEMBER)); + expect(result).toEqual({ triggered: 0, skipped: 0, steered: 1 }); + expect(requests).toHaveBeenCalledTimes(1); + expect(requests.mock.calls[0][1]).toBe("2-allowed"); + expect(authorization).toHaveBeenCalledTimes(1); + expect(memberships).toHaveBeenCalledTimes(1); + expect(sessionGet.mock.calls).toEqual(targets.map(({ sessionId }) => [sessionId])); + for (const { automationId } of targets) await expectOriginalRunOnly(automationId); + }); +}); diff --git a/packages/control-plane/test/integration/scoped-installation-token.test.ts b/packages/control-plane/test/integration/scoped-installation-token.test.ts new file mode 100644 index 0000000000..8aea8b8f22 --- /dev/null +++ b/packages/control-plane/test/integration/scoped-installation-token.test.ts @@ -0,0 +1,319 @@ +import { env } from "cloudflare:test"; +import { createKvCacheStore } from "@open-inspect/shared/cache-store"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + getCachedInstallationToken, + getInstallationTokenCacheKey, + type GitHubAppConfig, + type TokenScope, +} from "../../src/auth/github-app"; +import { resolveSessionCredentialScope } from "../../src/source-control/session-scope"; +import { SessionIndexStore } from "../../src/db/session-index"; +import { resolveImageBuildTokenScope } from "../../src/image-builds/credential-scope"; +import { + readCachedInstallationRepositories, + REPOS_CACHE_KEY, + reposCacheIdentity, +} from "../../src/repos/cache"; +import { GitHubSourceControlProvider } from "../../src/source-control/providers/github-provider"; +import { cleanD1Tables } from "./cleanup"; + +let privateKey: string; +const cacheStore = createKvCacheStore(env.REPOS_CACHE); +const cacheBindings = { cacheStore, userAgent: "scoped-token-test" }; +const catalogEnv = { REPOS_CACHE: cacheStore, GITHUB_APP_INSTALLATION_ID: "installation-1" }; +const loadCatalog = () => readCachedInstallationRepositories(catalogEnv); + +beforeAll(async () => { + const pair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"] + ); + if (!("privateKey" in pair)) throw new Error("Expected an RSA key pair"); + const exported = await crypto.subtle.exportKey("pkcs8", pair.privateKey); + if (!(exported instanceof ArrayBuffer)) throw new Error("Expected a PKCS#8 byte buffer"); + const bytes = new Uint8Array(exported); + privateKey = `-----BEGIN PRIVATE KEY-----\n${btoa(String.fromCharCode(...bytes))}\n-----END PRIVATE KEY-----`; +}); + +beforeEach(cleanD1Tables); +afterEach(() => vi.restoreAllMocks()); + +function config(testName: string): GitHubAppConfig { + return { appId: `scope-${testName}`, installationId: "installation-1", privateKey }; +} + +async function seedTeam(id: string): Promise { + await env.DB.prepare( + "INSERT INTO teams (id, slug, name, created_at, updated_at) VALUES (?, ?, ?, 1, 1)" + ) + .bind(id, id, id) + .run(); +} + +async function grant(teamId: string, repositoryId: number): Promise { + await env.DB.prepare( + `INSERT INTO team_repository_grants + (id, team_id, grant_kind, repo_external_id, repo_owner, repo_name, created_at) + VALUES (?, ?, 'repository', ?, 'acme', ?, 1)` + ) + .bind(`${teamId}-${repositoryId}`, teamId, repositoryId, `repo-${repositoryId}`) + .run(); +} + +async function seedSession( + id: string, + ids: number[], + ownerTeamId: string | null = null +): Promise { + const repositories = ids.map((repoId) => ({ + repoOwner: "acme", + repoName: `repo-${repoId}`, + repoId, + baseBranch: "main", + })); + await new SessionIndexStore(env.DB).create({ + id, + ownerTeamId, + visibility: "workspace", + title: null, + repoOwner: repositories[0]?.repoOwner ?? null, + repoName: repositories[0]?.repoName ?? null, + repositories, + model: "anthropic/claude-haiku-4-5", + reasoningEffort: null, + baseBranch: repositories.length ? "main" : null, + status: "active", + createdAt: 1, + updatedAt: 1, + }); +} + +const sessionScope = (sessionId: string) => + resolveSessionCredentialScope(env.DB, sessionId, loadCatalog); + +function mockMint() { + let count = 0; + return vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + if (typeof input !== "string" || !input.endsWith("/access_tokens")) { + throw new Error("Unexpected outbound request in scoped-token test"); + } + expect(init?.method).toBe("POST"); + return Response.json({ + token: `scoped-token-${++count}`, + expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }); + }); +} + +describe("session repository installation tokens over D1 and KV", () => { + it.each([null, "team_a"])( + "never shares tokens between different session repositories for owner %s", + async (ownerTeamId) => { + await seedTeam("team_a"); + await grant("team_a", 12); + await grant("team_a", 2); + await grant("team_a", 99); + await grant("team_a", 77); + await seedSession("session-a", [12, 2], ownerTeamId); + await seedSession("session-b", [99], ownerTeamId); + const mint = mockMint(); + const app = config(`different-sessions-${ownerTeamId ?? "workspace"}`); + const scopeA = await sessionScope("session-a"); + const scopeB = await sessionScope("session-b"); + + const tokenA = await getCachedInstallationToken(app, cacheBindings, { scope: scopeA }); + const tokenB = await getCachedInstallationToken(app, cacheBindings, { scope: scopeB }); + expect(tokenB).not.toBe(tokenA); + expect(mint.mock.calls.map(([, init]) => JSON.parse(String(init?.body)))).toEqual([ + { repository_ids: [2, 12] }, + { repository_ids: [99] }, + ]); + const keyA = await getInstallationTokenCacheKey(app, scopeA); + const keyB = await getInstallationTokenCacheKey(app, scopeB); + expect(await cacheStore.get(keyA, "json")).toMatchObject({ token: tokenA }); + expect(await cacheStore.get(keyB, "json")).toMatchObject({ token: tokenB }); + expect(await getCachedInstallationToken(app, cacheBindings, { scope: scopeA })).toBe(tokenA); + expect(await getCachedInstallationToken(app, cacheBindings, { scope: scopeB })).toBe(tokenB); + expect(mint).toHaveBeenCalledTimes(2); + } + ); + + it("mints once for a newly added grant and never reuses a broader token after removal", async () => { + await seedTeam("team_a"); + await grant("team_a", 12); + await seedSession("session-a", [12, 99], "team_a"); + const mint = mockMint(); + const app = config("grant-changes"); + const original = await sessionScope("session-a"); + const tokenOriginal = await getCachedInstallationToken(app, cacheBindings, { scope: original }); + + await grant("team_a", 99); + const expanded = await sessionScope("session-a"); + const tokenExpanded = await getCachedInstallationToken(app, cacheBindings, { scope: expanded }); + expect(tokenExpanded).not.toBe(tokenOriginal); + expect(await getInstallationTokenCacheKey(app, expanded)).not.toBe( + await getInstallationTokenCacheKey(app, original) + ); + expect(mint).toHaveBeenCalledTimes(2); + + await env.DB.prepare( + "DELETE FROM team_repository_grants WHERE team_id = ? AND repo_external_id = ?" + ) + .bind("team_a", 12) + .run(); + const narrowed = await sessionScope("session-a"); + const tokenNarrowed = await getCachedInstallationToken(app, cacheBindings, { scope: narrowed }); + expect(tokenNarrowed).not.toBe(tokenExpanded); + expect(mint).toHaveBeenCalledTimes(3); + expect(JSON.parse(String(mint.mock.calls[2][1]?.body))).toEqual({ repository_ids: [99] }); + }); + + it("returns no token for a team with no grants even when an all-scope token is cached", async () => { + await seedTeam("team_empty"); + await seedSession("session-empty-grants", [12], "team_empty"); + const mint = mockMint(); + const app = config("empty-team"); + await getCachedInstallationToken(app, cacheBindings, { scope: { kind: "all" } }); + expect(mint.mock.calls[0][1]?.body).toBeUndefined(); + mint.mockClear(); + await expect(sessionScope("session-empty-grants")).rejects.toThrow("no repositories"); + expect(mint).not.toHaveBeenCalled(); + }); + + it("installation grants retain only session members, never other installation repositories", async () => { + await seedTeam("team_all"); + await env.DB.prepare( + "INSERT INTO team_repository_grants (id, team_id, grant_kind, created_at) VALUES (?, ?, 'installation', 1)" + ) + .bind("grant-all", "team_all") + .run(); + await seedSession("session-installation-grant", [12], "team_all"); + const mint = mockMint(); + await getCachedInstallationToken(config("installation-grant-session"), cacheBindings, { + scope: await sessionScope("session-installation-grant"), + }); + expect(JSON.parse(String(mint.mock.calls[0][1]?.body))).toEqual({ repository_ids: [12] }); + }); + + it.each(["NULL member", "scalar fallback"])( + "resolves a legacy %s from the cached catalog and refuses an unresolved identity", + async (kind) => { + await seedSession("session-null-id", [12]); + if (kind === "scalar fallback") { + await env.DB.prepare("DELETE FROM session_repositories WHERE session_id = ?") + .bind("session-null-id") + .run(); + } else { + await env.DB.prepare("UPDATE session_repositories SET repo_id = NULL WHERE session_id = ?") + .bind("session-null-id") + .run(); + } + await cacheStore.put( + REPOS_CACHE_KEY, + JSON.stringify({ + scmIdentity: await reposCacheIdentity(catalogEnv), + cachedAt: new Date().toISOString(), + repos: [12, 99].map((id) => ({ + id, + owner: "acme", + name: `repo-${id}`, + fullName: `acme/repo-${id}`, + description: null, + private: true, + archived: false, + defaultBranch: "main", + })), + }) + ); + const mint = mockMint(); + await getCachedInstallationToken(config(`legacy-session-${kind}`), cacheBindings, { + scope: await sessionScope("session-null-id"), + }); + expect(JSON.parse(String(mint.mock.calls[0][1]?.body))).toEqual({ repository_ids: [12] }); + await env.DB.prepare( + kind === "scalar fallback" + ? "UPDATE sessions SET repo_name = ? WHERE id = ?" + : "UPDATE session_repositories SET repo_name = ? WHERE session_id = ?" + ) + .bind("missing", "session-null-id") + .run(); + mint.mockClear(); + await expect(sessionScope("session-null-id")).rejects.toThrow("repository id unavailable"); + expect(mint).not.toHaveBeenCalled(); + } + ); + + it("mints a repository image-build token for that repository alone with no team grants", async () => { + const mint = mockMint(); + const scope = await resolveImageBuildTokenScope( + env.DB, + { kind: "repo", id: "acme/repo-12" }, + { + kind: "repo", + repoId: 12, + repositories: [{ repoOwner: "acme", repoName: "repo-12", baseBranch: "main" }], + repositoriesFingerprint: "test-fingerprint", + }, + loadCatalog + ); + await getCachedInstallationToken(config("repository-build"), cacheBindings, { scope }); + expect(JSON.parse(String(mint.mock.calls[0][1]?.body))).toEqual({ repository_ids: [12] }); + }); + + it("recovers a provider 401 through the real scoped caches without evicting another scope", async () => { + const app = config("provider-401"); + const scopeA: TokenScope = { kind: "repositories", repositoryIds: [12] }; + const scopeB: TokenScope = { kind: "repositories", repositoryIds: [99] }; + const keyA = await getInstallationTokenCacheKey(app, scopeA); + const keyB = await getInstallationTokenCacheKey(app, scopeB); + for (const [key, token] of [ + [keyA, "rejected-token"], + [keyB, "unaffected-token"], + ]) { + await cacheStore.put( + key, + JSON.stringify({ + token, + expiresAtEpochMs: Date.now() + 60 * 60 * 1000, + cachedAtEpochMs: Date.now(), + }) + ); + } + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + if (typeof input !== "string") throw new Error("Unexpected request input"); + if (input.endsWith("/access_tokens")) { + expect(JSON.parse(String(init?.body))).toEqual({ repository_ids: [12] }); + return Response.json({ + token: "replacement-token", + expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }); + } + expect(input).toBe("https://api.github.com/repos/acme/repo-12/git/ref/heads/main"); + if (new Headers(init?.headers).get("Authorization") === "Bearer rejected-token") { + return new Response("Unauthorized", { status: 401 }); + } + expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer replacement-token"); + return Response.json({ object: { sha: "branch-sha" } }); + }); + const provider = new GitHubSourceControlProvider({ appConfig: app, cacheStore }); + expect( + await provider.getBranchHead({ owner: "acme", name: "repo-12", branch: "main" }, scopeA) + ).toBe("branch-sha"); + expect(await getCachedInstallationToken(app, cacheBindings, { scope: scopeA })).toBe( + "replacement-token" + ); + expect(await getCachedInstallationToken(app, cacheBindings, { scope: scopeB })).toBe( + "unaffected-token" + ); + expect(await cacheStore.get(keyA, "json")).toMatchObject({ token: "replacement-token" }); + expect(await cacheStore.get(keyB, "json")).toMatchObject({ token: "unaffected-token" }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); +}); diff --git a/packages/control-plane/test/integration/session-environment-ownership.test.ts b/packages/control-plane/test/integration/session-environment-ownership.test.ts new file mode 100644 index 0000000000..ed24e8b360 --- /dev/null +++ b/packages/control-plane/test/integration/session-environment-ownership.test.ts @@ -0,0 +1,288 @@ +import { createExecutionContext, env } from "cloudflare:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BUILT_IN_ROLE_REGISTRY } from "@open-inspect/shared/rbac"; +import type { SessionVisibility } from "@open-inspect/shared/types/teams"; +import { EnvironmentStore } from "../../src/db/environments"; +import { EnvironmentSecretsStore } from "../../src/db/environment-secrets"; +import { RepoSecretsStore } from "../../src/db/repo-secrets"; +import { SessionIndexStore } from "../../src/db/session-index"; +import * as repositoryResolution from "../../src/repos/resolve"; +import * as routeShared from "../../src/routes/shared"; +import * as integrationSettings from "../../src/session/integration-settings-resolution"; +import { cleanD1Tables } from "./cleanup"; +import { + initSession, + queryDO, + routeRequest, + seedMessage, + seedSandboxAuth, + serviceFetch, + serviceRequestHeaders, + waitForSandboxStatus, +} from "./helpers"; +import { + assignCustomRole, + ownershipRequest, + seedEnvironment, + seedGrant, + seedTeam, +} from "./ownership-test-helpers"; +import { getUserEnvVars } from "./session-do-access"; + +const BASE = "https://test.local"; +const MEMBER = "22222222222222222222222222222222"; +const TEAM_A = "team_a"; +const TEAM_B = "team_b"; +const TEAM_ENV = "env_team_a"; +const WORKSPACE_ENV = "env_workspace"; +const WEB = { repoOwner: "acme/group", repoName: "web", repoId: 1 }; +const BASE_BRANCH = "main"; +const store = new SessionIndexStore(env.DB); + +function createSession(body: object) { + return ownershipRequest("/sessions", { + method: "POST", + body: JSON.stringify({ + title: "Environment launch", + model: "anthropic/claude-haiku-4-5", + ...body, + }), + as: { userId: MEMBER, role: "member" }, + }); +} + +async function sandboxParent( + environmentId: string, + ownerTeamId: string | null, + visibility: SessionVisibility = "workspace" +) { + const parent = await initSession({ + sessionName: `ownership-parent-${crypto.randomUUID()}`, + ...WEB, + defaultBranch: BASE_BRANCH, + environmentId, + userId: MEMBER, + scmLogin: "environment-member", + }); + await env.DB.prepare("UPDATE sessions SET owner_team_id = ?, visibility = ? WHERE id = ?") + .bind(ownerTeamId, visibility, parent.sessionName) + .run(); + const sandboxToken = `sandbox-${crypto.randomUUID()}`; + await seedSandboxAuth(parent.stub, { + authToken: sandboxToken, + sandboxId: `sb-${parent.sessionName}`, + }); + const [owner] = await queryDO<{ id: string }>( + parent.stub, + "SELECT id FROM participants WHERE role = 'owner'" + ); + if (!owner) throw new Error("Expected parent owner participant"); + await seedMessage(parent.stub, { + id: `processing-${parent.sessionName}`, + authorId: owner.id, + content: "Spawn a child", + source: "web", + status: "processing", + createdAt: Date.now(), + startedAt: Date.now(), + }); + return { + ...parent, + spawn: () => + routeRequest( + new Request(`${BASE}/sessions/${parent.sessionName}/children`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${sandboxToken}` }, + body: JSON.stringify({ title: "Inherited target", prompt: "Investigate" }), + }), + env, + createExecutionContext() + ), + }; +} + +async function expectCloneContext(sessionId: string, environmentId: string) { + const stub = env.SESSION.get(env.SESSION.idFromName(sessionId)); + expect( + await queryDO( + stub, + "SELECT environment_id AS environmentId, repo_owner AS repoOwner, repo_name AS repoName, repo_id AS repoId FROM session" + ) + ).toEqual([{ environmentId, ...WEB }]); + await waitForSandboxStatus(stub, "failed"); + return stub; +} + +describe("session environment ownership compatibility", () => { + beforeEach(async () => { + await cleanD1Tables(); + await serviceRequestHeaders(`${BASE}/me/authorization`, { + as: { userId: MEMBER, role: "member" }, + }); + for (const teamId of [TEAM_A, TEAM_B]) { + await seedTeam(teamId, [[MEMBER, "member"]]); + await seedGrant(teamId, "installation"); + } + await env.DB.prepare( + "UPDATE user_identities SET provider_login = 'environment-member' WHERE user_id = ? AND provider = 'github'" + ) + .bind(MEMBER) + .run(); + const repositories = [ + { + position: 0, + repo_owner: WEB.repoOwner, + repo_name: WEB.repoName, + repo_id: WEB.repoId, + base_branch: BASE_BRANCH, + }, + ]; + await seedEnvironment(TEAM_ENV, TEAM_A, repositories); + await seedEnvironment(WORKSPACE_ENV, null, repositories); + vi.spyOn(repositoryResolution, "resolveSessionRepositories").mockImplementation( + async (_env, repos) => + repos.map((repo) => ({ + ...repo, + repoId: WEB.repoId, + baseBranch: repo.baseBranch ?? BASE_BRANCH, + })) + ); + // Team-owned children re-resolve the parent repository before checking team grants. + vi.spyOn(routeShared, "resolveRepoOrError").mockResolvedValue({ + ...WEB, + defaultBranch: BASE_BRANCH, + }); + }); + afterEach(() => vi.restoreAllMocks()); + + it.each([{}, { teamId: TEAM_B, visibility: "private" }])( + "rejects visible team targets for a different destination %j before resolution/writes", + async (body) => { + const resolveTarget = vi.spyOn(repositoryResolution, "resolveEnvironmentTarget"); + const response = await createSession({ environmentId: TEAM_ENV, ...body }); + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + code: "environment_team_mismatch", + reason_code: "environment_team_mismatch", + }); + expect(resolveTarget).not.toHaveBeenCalled(); + expect(repositoryResolution.resolveSessionRepositories).not.toHaveBeenCalled(); + expect(await env.DB.prepare("SELECT COUNT(*) AS count FROM sessions").first()).toEqual({ + count: 0, + }); + } + ); + + it.each([ + [TEAM_ENV, TEAM_A, "private"], + [WORKSPACE_ENV, null, "workspace"], + [WORKSPACE_ENV, TEAM_A, "team"], + [WORKSPACE_ENV, TEAM_B, "team"], + ] as const)( + "admits use-only launch of %s into %s/%s and persists scope", + async (environmentId, teamId, visibility) => { + await assignCustomRole(MEMBER, ["sessions.create", "environments.use"]); + const read = await serviceFetch(`${BASE}/environments/${environmentId}`, { + as: { userId: MEMBER, role: "member" }, + }); + expect(read.status).toBe(403); + const response = await createSession({ environmentId, teamId, visibility }); + expect(response.status).toBe(201); + const { sessionId } = await response.json<{ sessionId: string }>(); + expect(await store.get(sessionId)).toMatchObject({ + ownerTeamId: teamId, + visibility, + environmentId, + userId: MEMBER, + }); + expect(repositoryResolution.resolveSessionRepositories).toHaveBeenCalledTimes(1); + await waitForSandboxStatus(env.SESSION.get(env.SESSION.idFromName(sessionId)), "failed"); + } + ); + + it("conceals a nonmember's environment like missing before destination mismatch", async () => { + await env.DB.prepare("DELETE FROM team_memberships WHERE team_id = ? AND user_id = ?") + .bind(TEAM_A, MEMBER) + .run(); + const resolveTarget = vi.spyOn(repositoryResolution, "resolveEnvironmentTarget"); + for (const environmentId of [TEAM_ENV, "env_missing"]) { + const response = await createSession({ environmentId, teamId: TEAM_B }); + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ error: "Environment not found" }); + } + expect(resolveTarget).not.toHaveBeenCalled(); + expect(await env.DB.prepare("SELECT COUNT(*) AS count FROM sessions").first()).toEqual({ + count: 0, + }); + }); + + it("rejects incompatible sandbox inheritance before settings, child writes or leases", async () => { + const parent = await sandboxParent(TEAM_ENV, TEAM_B, "private"); + const settings = vi.spyOn(integrationSettings, "resolveSandboxSettings"); + const response = await parent.spawn(); + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + code: "environment_team_mismatch", + reason_code: "environment_team_mismatch", + }); + expect(settings).not.toHaveBeenCalled(); + expect(await store.countTotalChildren(parent.sessionName)).toBe(0); + expect( + await env.DB.prepare("SELECT COUNT(*) AS count FROM child_admission_leases").first() + ).toEqual({ count: 0 }); + }); + + it.each([ + [TEAM_ENV, TEAM_A, "private"], + [WORKSPACE_ENV, null, "workspace"], + [WORKSPACE_ENV, TEAM_B, "team"], + ] as const)( + "inherits %s into %s/%s without human use access", + async (environmentId, ownerTeamId, visibility) => { + const parent = await sandboxParent(environmentId, ownerTeamId, visibility); + await env.DB.prepare("DELETE FROM team_memberships WHERE user_id = ?").bind(MEMBER).run(); + await env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?") + .bind(BUILT_IN_ROLE_REGISTRY.viewer.id, MEMBER) + .run(); + const response = await parent.spawn(); + expect(response.status).toBe(201); + expect(routeShared.resolveRepoOrError).toHaveBeenCalledTimes(ownerTeamId ? 1 : 0); + const { sessionId } = await response.json<{ sessionId: string }>(); + expect(await store.get(sessionId)).toMatchObject({ + parentSessionId: parent.sessionName, + ownerTeamId, + visibility, + environmentId, + repoOwner: WEB.repoOwner, + repoName: WEB.repoName, + baseBranch: BASE_BRANCH, + }); + await expectCloneContext(sessionId, environmentId); + } + ); + + it.each(["missing", "deleted"])( + "preserves %s provenance and clone context without target secrets", + async (state) => { + const environmentId = state === "deleted" ? TEAM_ENV : "env_missing"; + const key = env.REPO_SECRETS_ENCRYPTION_KEY!; + await new EnvironmentSecretsStore(env.DB, key).setSecrets(TEAM_ENV, { + ENV_ONLY: "environment", + }); + await new RepoSecretsStore(env.DB, key).setSecrets(WEB.repoId, WEB.repoOwner, WEB.repoName, { + REPO_ONLY: "repository", + }); + const parent = await sandboxParent(environmentId, TEAM_A); + if (state === "deleted") await new EnvironmentStore(env.DB).delete(environmentId); + const response = await parent.spawn(); + expect(response.status).toBe(201); + const { sessionId } = await response.json<{ sessionId: string }>(); + expect(await store.get(sessionId)).toMatchObject({ environmentId, ownerTeamId: TEAM_A }); + expect(routeShared.resolveRepoOrError).toHaveBeenCalledOnce(); + const stub = await expectCloneContext(sessionId, environmentId); + const secrets = (await getUserEnvVars(stub)) ?? {}; + expect(secrets).not.toHaveProperty("ENV_ONLY"); + expect(secrets).not.toHaveProperty("REPO_ONLY"); + } + ); +}); diff --git a/packages/control-plane/test/integration/spawn-children.test.ts b/packages/control-plane/test/integration/spawn-children.test.ts index 0f5fad4807..6372003b23 100644 --- a/packages/control-plane/test/integration/spawn-children.test.ts +++ b/packages/control-plane/test/integration/spawn-children.test.ts @@ -1,8 +1,10 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { SELF, env } from "cloudflare:test"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { SELF, env, createExecutionContext } from "cloudflare:test"; import { runInSessionDO } from "./session-do-access"; import type { SessionDO } from "../../src/cloudflare/durable-object"; import { SessionIndexStore } from "../../src/db/session-index"; +import { TeamRepositoryGrantStore } from "../../src/db/team-repository-grants"; +import { GitHubSourceControlProvider } from "../../src/source-control/providers/github-provider"; import { cleanD1Tables } from "./cleanup"; import { initNamedSessionDO, @@ -10,10 +12,12 @@ import { seedActiveUser, seedMessage, seedSandboxAuth, + routeRequest, } from "./helpers"; describe("POST /sessions/:parentId/children — spawn child", () => { beforeEach(cleanD1Tables); + afterEach(() => vi.restoreAllMocks()); /** Sets up a parent DO + sandbox auth + D1 row, returns everything needed for spawn tests. */ async function setupParent(opts?: { @@ -120,6 +124,18 @@ describe("POST /sessions/:parentId/children — spawn child", () => { await env.DB.prepare( "INSERT INTO teams (id, slug, name, created_at, updated_at) VALUES ('team_child', 'child', 'Child', 1, 1)" ).run(); + await new TeamRepositoryGrantStore(env.DB).add("team_child", { + kind: "repository", + repoExternalId: 12345, + owner: "acme", + name: "web-app", + }); + vi.spyOn(GitHubSourceControlProvider.prototype, "checkRepositoryAccess").mockResolvedValue({ + repoId: 12345, + repoOwner: "acme", + repoName: "web-app", + defaultBranch: "main", + }); const { parentName, sandboxToken, store } = await setupParent({ ownerTeamId: "team_child", visibility: "workspace", @@ -129,17 +145,21 @@ describe("POST /sessions/:parentId/children — spawn child", () => { scmLogin: "acmedev", }); - const res = await SELF.fetch(`https://test.local/sessions/${parentName}/children`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${sandboxToken}`, - }, - body: JSON.stringify({ - title: "Fix the tests", - prompt: "Please fix the failing tests in src/utils.ts", + const res = await routeRequest( + new Request(`https://test.local/sessions/${parentName}/children`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${sandboxToken}`, + }, + body: JSON.stringify({ + title: "Fix the tests", + prompt: "Please fix the failing tests in src/utils.ts", + }), }), - }); + env, + createExecutionContext() + ); expect(res.status).toBe(201); const body = await res.json<{ sessionId: string; status: string }>(); diff --git a/packages/control-plane/test/integration/team-grants.test.ts b/packages/control-plane/test/integration/team-grants.test.ts new file mode 100644 index 0000000000..a5a3f80016 --- /dev/null +++ b/packages/control-plane/test/integration/team-grants.test.ts @@ -0,0 +1,289 @@ +import { createExecutionContext, env } from "cloudflare:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TeamStore } from "../../src/db/teams"; +import { TeamMembershipStore } from "../../src/db/team-memberships"; +import { TeamRepositoryGrantStore } from "../../src/db/team-repository-grants"; +import { EnvironmentStore } from "../../src/db/environments"; +import { REPOS_CACHE_KEY, reposCacheIdentity } from "../../src/routes/repos"; +import { GitHubSourceControlProvider } from "../../src/source-control/providers/github-provider"; +import { cleanD1Tables } from "./cleanup"; +import { routeRequest, seedActiveUser, serviceRequestHeaders } from "./helpers"; + +const MEMBER = "22222222222222222222222222222222"; +const OTHER = "33333333333333333333333333333333"; +const repos = [1, 2].map((id) => ({ + id, + owner: "acme", + name: `repo-${id}`, + fullName: `acme/repo-${id}`, + description: null, + private: true, + archived: false, + defaultBranch: "main", +})); + +async function request(path: string, method = "GET", body?: object, userId?: string) { + const url = `https://test.local${path}`; + const payload = body ? JSON.stringify(body) : undefined; + const headers = await serviceRequestHeaders(url, { + method, + body: payload, + ...(userId ? { as: { userId, role: "member" } } : {}), + }); + return routeRequest( + new Request(url, { method, headers, body: payload }), + env, + createExecutionContext() + ); +} + +describe("team repository grants", () => { + let teamId: string; + beforeEach(async () => { + await cleanD1Tables(); + await seedActiveUser(MEMBER); + await seedActiveUser(OTHER); + await request("/me/authorization"); + teamId = ( + await new TeamStore(env.DB).create({ + slug: "engineering", + name: "Engineering", + joinPolicy: "invite_only", + }) + ).id; + await new TeamMembershipStore(env.DB).add(teamId, MEMBER); + await env.REPOS_CACHE.put( + REPOS_CACHE_KEY, + JSON.stringify({ + repos, + cachedAt: "2026-10-01T00:00:00.000Z", + freshUntil: Date.now() + 60_000, + scmIdentity: await reposCacheIdentity(env), + }) + ); + vi.spyOn(GitHubSourceControlProvider.prototype, "checkRepositoryAccess").mockImplementation( + async ({ owner, name }) => ({ + repoId: Number(name.slice("repo-".length)), + repoOwner: owner, + repoName: name, + defaultBranch: "main", + }) + ); + }); + afterEach(() => vi.restoreAllMocks()); + + it("filters a cached catalog by numeric grants while leaving the workspace list full", async () => { + await new TeamRepositoryGrantStore(env.DB).add(teamId, { + kind: "repository", + repoExternalId: 1, + owner: "acme", + name: "repo-1", + }); + const response = await request(`/repos?teamId=${teamId}`, "GET", undefined, MEMBER); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + repos: [repos[0]], + teamHasRepositoryGrants: true, + }); + expect(await (await request("/repos")).json()).toMatchObject({ repos }); + }); + + it("serves the full installation catalog for an installation grant", async () => { + await new TeamRepositoryGrantStore(env.DB).add(teamId, { kind: "installation" }); + expect(await (await request(`/repos?teamId=${teamId}`)).json()).toMatchObject({ + repos, + teamHasRepositoryGrants: true, + }); + }); + + it("identifies a team with no grants without falling back to the installation list", async () => { + expect(await (await request(`/repos?teamId=${teamId}`)).json()).toMatchObject({ + repos: [], + teamHasRepositoryGrants: false, + }); + }); + + it.each(["/repos?teamId=", "/teams/"])("conceals a nonmember's %s grant read", async (prefix) => { + const path = + prefix === "/teams/" ? `${prefix}${teamId}/repository-grants` : `${prefix}${teamId}`; + const response = await request(path, "GET", undefined, OTHER); + expect(response.status).toBe(404); + expect(await response.json()).toMatchObject({ error: "Team not found" }); + const denial = + prefix === "/repos?teamId=" + ? await env.DB.prepare( + "SELECT action, reason_code FROM authorization_audit_events WHERE team_id = ? AND operation_result = 'denied'" + ) + .bind(teamId) + .all() + : await env.DB.prepare( + "SELECT action, reason_code FROM authorization_audit_events WHERE operation_result = 'denied'" + ).all(); + expect(denial.results).toContainEqual({ + action: "authorization.request_denied", + reason_code: "team_not_visible", + }); + }); + + it("audits missing teams identically to teams hidden from a nonmember", async () => { + const visible = await request(`/teams/${teamId}/repository-grants`, "GET", undefined, OTHER); + const missing = await request("/teams/team_missing/repository-grants", "GET", undefined, OTHER); + expect(missing.status).toBe(visible.status); + expect(await missing.json()).toEqual(await visible.json()); + const rows = await env.DB.prepare( + "SELECT reason_code, operation_result FROM authorization_audit_events WHERE action = 'authorization.request_denied'" + ).all(); + expect(rows.results).toHaveLength(2); + expect(rows.results).toEqual([ + { reason_code: "team_not_visible", operation_result: "denied" }, + { reason_code: "team_not_visible", operation_result: "denied" }, + ]); + }); + + it("lets a member read without audit rows but refuses grant changes", async () => { + const before = await env.DB.prepare( + "SELECT COUNT(*) AS count FROM authorization_audit_events" + ).first(); + const response = await request(`/teams/${teamId}/repository-grants`, "GET", undefined, MEMBER); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ grants: [] }); + expect( + await env.DB.prepare("SELECT COUNT(*) AS count FROM authorization_audit_events").first() + ).toEqual(before); + expect( + (await request(`/teams/${teamId}/repository-grants`, "PUT", { kind: "installation" }, MEMBER)) + .status + ).toBe(403); + }); + + it("adds and removes grants atomically with a version bump and before/after audit", async () => { + const added = await request(`/teams/${teamId}/repository-grants`, "PUT", { + kind: "repository", + repoExternalId: 1, + owner: "acme", + name: "repo-1", + }); + expect(added.status).toBe(200); + const { grant } = await added.json<{ grant: { id: string } }>(); + expect((await new TeamStore(env.DB).getById(teamId))?.grantsVersion).toBe(1); + expect((await request(`/teams/${teamId}/repository-grants/${grant.id}`, "DELETE")).status).toBe( + 204 + ); + expect((await new TeamStore(env.DB).getById(teamId))?.grantsVersion).toBe(2); + const audit = await env.DB.prepare( + "SELECT action, metadata_json FROM authorization_audit_events WHERE team_id = ? AND action IN ('team.grant_added', 'team.grant_removed') ORDER BY occurred_at, id" + ) + .bind(teamId) + .all<{ action: string; metadata_json: string }>(); + expect(audit.results.map((row) => row.action)).toEqual([ + "team.grant_added", + "team.grant_removed", + ]); + expect(JSON.parse(audit.results[0].metadata_json)).toMatchObject({ + before: {}, + after: { id: grant.id }, + }); + expect(JSON.parse(audit.results[1].metadata_json)).toMatchObject({ + before: { id: grant.id }, + after: {}, + }); + }); + + it("rejects a forged external repository ID before granting it", async () => { + const response = await request(`/teams/${teamId}/repository-grants`, "PUT", { + kind: "repository", + repoExternalId: 99, + owner: "acme", + name: "repo-1", + }); + expect(response.status).toBe(409); + expect(await new TeamRepositoryGrantStore(env.DB).listForTeam(teamId)).toEqual([]); + }); + + it("lets a team lead manage grants without workspace administration", async () => { + await new TeamMembershipStore(env.DB).setRole(teamId, MEMBER, "lead"); + const response = await request( + `/teams/${teamId}/repository-grants`, + "PUT", + { kind: "installation" }, + MEMBER + ); + expect(response.status).toBe(200); + const { grant } = await response.json<{ grant: { id: string } }>(); + expect( + (await request(`/teams/${teamId}/repository-grants/${grant.id}`, "DELETE", undefined, MEMBER)) + .status + ).toBe(204); + }); + + it("refuses a 501st named grant at the API without changing the version", async () => { + await env.DB.batch( + Array.from({ length: 500 }, (_, index) => + env.DB.prepare( + "INSERT INTO team_repository_grants (id, team_id, grant_kind, repo_external_id, repo_owner, repo_name, created_at) VALUES (?, ?, 'repository', ?, 'acme', ?, 1)" + ).bind(`grant-${index}`, teamId, index + 1, `repo-${index + 1}`) + ) + ); + const response = await request(`/teams/${teamId}/repository-grants`, "PUT", { + kind: "repository", + repoExternalId: 501, + owner: "acme", + name: "repo-501", + }); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ code: "repository_grant_limit" }); + expect(await new TeamRepositoryGrantStore(env.DB).listForTeam(teamId)).toHaveLength(500); + expect((await new TeamStore(env.DB).getById(teamId))?.grantsVersion).toBe(0); + }); + + it("denies session creation with an ungranted repository before creating a session", async () => { + const response = await request( + "/sessions", + "POST", + { teamId, repoOwner: "acme", repoName: "repo-2" }, + MEMBER + ); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ + code: "target_team_missing_grant", + repository: "acme/repo-2", + }); + expect(await env.DB.prepare("SELECT COUNT(*) AS count FROM sessions").first()).toEqual({ + count: 0, + }); + }); + + it("denies adding an ungranted repository to a team-owned environment without changing its members", async () => { + const store = new EnvironmentStore(env.DB); + await store.create( + { + id: "env_granted", + owner_team_id: teamId, + name: "Granted", + description: null, + prebuild_enabled: 0, + channel_associations: null, + created_at: 1, + updated_at: 1, + }, + [{ position: 0, repo_owner: "acme", repo_name: "repo-1", repo_id: 1, base_branch: "main" }] + ); + await new TeamRepositoryGrantStore(env.DB).add(teamId, { + kind: "repository", + repoExternalId: 1, + owner: "acme", + name: "repo-1", + }); + const response = await request("/environments/env_granted", "PUT", { + repositories: [{ repoOwner: "acme", repoName: "repo-2" }], + }); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ + code: "target_team_missing_grant", + repository: "acme/repo-2", + }); + expect( + (await store.getRepositoriesForEnvironment("env_granted")).map((repo) => repo.repo_id) + ).toEqual([1]); + }); +}); diff --git a/packages/control-plane/test/integration/teams-routes.test.ts b/packages/control-plane/test/integration/teams-routes.test.ts index 3e5b123ae7..2f1817ebf8 100644 --- a/packages/control-plane/test/integration/teams-routes.test.ts +++ b/packages/control-plane/test/integration/teams-routes.test.ts @@ -238,6 +238,115 @@ describe("team routes", () => { ); }); + describe("when the operation audit cannot be written", () => { + const memberRole = async (teamId: string, userId: string) => + (await new TeamMembershipStore(env.DB).listForUser(userId)).get(teamId); + const seed = async (joinPolicy: "open" | "invite_only" = "invite_only") => { + const team = await new TeamStore(env.DB).create({ + slug: "audited", + name: "Audited", + joinPolicy, + }); + await new TeamMembershipStore(env.DB).add(team.id, MEMBER, "lead"); + return team.id; + }; + const cases: Array<{ + action: string; + arrange: () => Promise; + mutate: (teamId: string) => Promise; + state: (teamId: string) => Promise; + }> = [ + { + action: "team.updated", + arrange: () => seed(), + mutate: (teamId) => request(`/teams/${teamId}`, "PATCH", { name: "Renamed" }), + state: async (teamId) => (await new TeamStore(env.DB).getById(teamId))?.name, + }, + { + action: "team.archived", + arrange: () => seed(), + mutate: (teamId) => request(`/teams/${teamId}/archive`, "POST"), + state: async (teamId) => (await new TeamStore(env.DB).getById(teamId))?.archivedAt, + }, + { + action: "team.restored", + arrange: async () => { + const teamId = await seed(); + await new TeamStore(env.DB).archive(teamId); + return teamId; + }, + mutate: (teamId) => request(`/teams/${teamId}/restore`, "POST"), + state: async (teamId) => (await new TeamStore(env.DB).getById(teamId))?.archivedAt, + }, + { + action: "team.member_added", + arrange: () => seed(), + mutate: (teamId) => request(`/teams/${teamId}/members/${OTHER}`, "PUT", { role: "member" }), + state: (teamId) => memberRole(teamId, OTHER), + }, + { + action: "team.member_role_changed", + arrange: async () => { + const teamId = await seed(); + await new TeamMembershipStore(env.DB).add(teamId, OTHER, "member"); + return teamId; + }, + mutate: (teamId) => request(`/teams/${teamId}/members/${OTHER}`, "PUT", { role: "lead" }), + state: (teamId) => memberRole(teamId, OTHER), + }, + { + action: "team.member_removed", + arrange: async () => { + const teamId = await seed(); + await new TeamMembershipStore(env.DB).add(teamId, OTHER, "member"); + return teamId; + }, + mutate: (teamId) => request(`/teams/${teamId}/members/${OTHER}`, "DELETE"), + state: (teamId) => memberRole(teamId, OTHER), + }, + { + action: "team.member_joined", + arrange: async () => { + await setRole(OWNER, "member"); + return await seed("open"); + }, + mutate: (teamId) => request(`/teams/${teamId}/join`, "POST"), + state: (teamId) => memberRole(teamId, OWNER), + }, + ]; + + it.each(cases)( + "rolls back $action with its audit row", + async ({ action, arrange, mutate, state }) => { + const teamId = await arrange(); + const before = await state(teamId); + await env.DB.prepare( + `CREATE TRIGGER fail_team_audit + BEFORE INSERT ON authorization_audit_events + WHEN NEW.resource_type = 'team' + BEGIN + SELECT RAISE(ABORT, 'forced audit failure'); + END` + ).run(); + try { + expect((await mutate(teamId)).status).toBe(500); + } finally { + await env.DB.prepare("DROP TRIGGER fail_team_audit").run(); + } + expect(await state(teamId)).toEqual(before); + expect(await auditEvents(teamId)).toEqual([]); + + expect((await mutate(teamId)).ok).toBe(true); + expect(await state(teamId)).not.toEqual(before); + const rows = await auditEvents(teamId); + expect(rows.map((row) => row.action)).toEqual([action]); + expect(JSON.parse(String(rows[0].metadata_json)).before).not.toEqual( + JSON.parse(String(rows[0].metadata_json)).after + ); + } + ); + }); + it("rejects invalid default environments and reports duplicate team slugs", async () => { const team = await new TeamStore(env.DB).create({ slug: "duplicate", diff --git a/packages/docs/content/docs/administration/security.mdx b/packages/docs/content/docs/administration/security.mdx index 87c4563eda..b83dcaab21 100644 --- a/packages/docs/content/docs/administration/security.mdx +++ b/packages/docs/content/docs/administration/security.mdx @@ -4,7 +4,7 @@ description: The trust boundaries of an OpenInspect deployment, which tokens exi audience: admin owner: security status: published -lastReviewed: "2026-09-28" +lastReviewed: "2026-10-01" relatedCode: - README.md - docs/AUTH.md @@ -82,10 +82,7 @@ flowchart TB Git credentials are brokered on demand. The helper authorizes HTTPS requests for the configured source-control host, so setup and start scripts that clone other private repositories available to the installation keep working. -| Sandbox start | How it gets git credentials | -| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| Fresh or prebuilt image | Fetches a short-lived credential from the control plane through a git credential helper. No token is embedded in the environment or the remote URL. | -| Snapshot restore | May still receive an environment-token fallback so older snapshots can boot. Modal snapshot restores mint a fresh fallback token on restore. | +Session sandboxes never receive a system git token in the environment or remote URL, whether they start fresh, from a prebuilt image, or from a snapshot. Each one fetches a short-lived credential from the control plane through a git credential helper. Image builds, which have no session, receive a one-shot clone token instead. The GitHub App private key is held only by the control plane and the GitHub bot, never by Modal or a sandbox. Pull requests use the prompting user's GitHub OAuth token when it is available. A missing token, failed refresh or decryption, or an ordinary retrieval failure falls back to the App identity even for a GitHub-signed-in user. Unexpected token-resolution errors fail PR creation instead. Google-signed-in users without a GitHub token also use the App. Branch pushes use App credentials regardless of who opens the pull request. diff --git a/packages/docs/content/docs/configure/prebuilt-images.mdx b/packages/docs/content/docs/configure/prebuilt-images.mdx index 9c82db2ba7..7f56c8479b 100644 --- a/packages/docs/content/docs/configure/prebuilt-images.mdx +++ b/packages/docs/content/docs/configure/prebuilt-images.mdx @@ -4,7 +4,7 @@ description: Enable prebuilt images for a repository or an environment so sessio audience: team-owner owner: platform status: published -lastReviewed: "2026-09-28" +lastReviewed: "2026-10-01" relatedCode: - docs/IMAGE_PREBUILD.md - docs/HOW_IT_WORKS.md @@ -53,6 +53,7 @@ The artifact is stored by the deployment's sandbox provider: | Provider | Artifact | | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Modal | Modal image | +| `modal-vm` | Modal image built on the VM backend. Images built under `modal` are not used by `modal-vm` sessions. | | Vercel Sandboxes | Vercel snapshot | | OpenComputer | Checkpoint | | E2B | Snapshot | diff --git a/packages/docs/content/docs/reference/sandbox-environment.mdx b/packages/docs/content/docs/reference/sandbox-environment.mdx index adff0c470d..8a517fb6bb 100644 --- a/packages/docs/content/docs/reference/sandbox-environment.mdx +++ b/packages/docs/content/docs/reference/sandbox-environment.mdx @@ -4,7 +4,7 @@ description: What is preinstalled in an OpenInspect sandbox, how the workspace i audience: team-owner owner: sandbox-runtime status: published -lastReviewed: "2026-09-28" +lastReviewed: "2026-10-01" relatedCode: - packages/sandbox-images/toolchain.json - packages/sandbox-images/targets.json @@ -69,7 +69,8 @@ Repository `.opencode/` and `.claude/` directories are read by their respective | `SANDBOX_AUTH_TOKEN` | Session sandboxes (not image builds) | The sandbox's own session-scoped token for control-plane calls. Image builds use separate callback credentials. | | `SESSION_CONFIG` | Always | Session sandboxes receive the session ID, repositories, MCP servers, and related settings. Image builds receive only the branch and repositories, without a session ID. | | `REPO_OWNER`, `REPO_NAME` | Repository sessions | The primary repository. | -| `GITHUB_APP_TOKEN` | Modal snapshot restores only | A clone token so snapshots taken before the credential helper can still boot; fresh and prebuilt-image sandboxes, and every other provider, use the credential helper instead. | +| `VCS_HOST`, `VCS_CLONE_USERNAME` | Always | The source-control host and HTTPS username the credential helper serves. | +| `VCS_CLONE_TOKEN` | Image builds only | A one-shot clone credential, because builds have no session to fetch credentials through. | | `RESTORED_FROM_SNAPSHOT` | Snapshot restores | Marks a restored sandbox. | | `OPENCODE_CONFIG_CONTENT` | OpenCode sessions | The generated OpenCode configuration. | | `PYTHONUNBUFFERED`, `PATH`, `HOME`, `USER`, `SHELL`, `TERM`, `PWD`, `LANG` | Always | Standard process environment. | @@ -86,7 +87,7 @@ The reserved names are listed in full in [Secrets](/configure/secrets). - User-supplied provider API keys in account mode. When a session is bound to a connected OpenAI, xAI, or Claude account, the matching user key is removed from the general sandbox environment and the `*_OAUTH_MANAGED` marker remains. A deployment-wide Anthropic key may still be present on Modal or OpenComputer. The Claude harness receives its setup token only in the `claude` process. - Refresh tokens. OpenAI and xAI sessions fetch short-lived access tokens; refresh tokens stay in the encrypted credential store. Claude accounts instead use a static setup token with an expiry, not a rotating refresh token. - The commit signing private key. Sandboxes receive only the public key; each commit is signed by the control plane. The key is never delivered to a sandbox file, environment, process, or snapshot. -- A long-lived git token in fresh and prebuilt-image sandboxes. Git credentials are minted on demand (next section). +- A long-lived git token in session sandboxes, including snapshot restores. Git credentials are minted on demand (next section). - The Claude account token outside the `claude` process. OpenCode, code-server, the terminal, and user shells never see it. Code the agent runs from Bash and hooks registered in the repository's `.claude/` settings do inherit it. ## Git credentials diff --git a/packages/github-bot/README.md b/packages/github-bot/README.md index fd654a5112..23539d87bc 100644 --- a/packages/github-bot/README.md +++ b/packages/github-bot/README.md @@ -132,10 +132,9 @@ For the agent to interact with GitHub from the sandbox, these prerequisites must 2. **Git credential helper** configured in the sandbox image/runtime so git operations can request short-lived SCM credentials from the control plane -Fresh and prebuilt-image sandboxes get GitHub CLI credentials through the helper rather than -spawn-time token injection. `GITHUB_TOKEN` and `GITHUB_APP_TOKEN` env fallbacks are only used for -legacy snapshots when the user has not provided an explicit GitHub CLI token. One-shot image-build -sandboxes use only the narrower `VCS_CLONE_TOKEN` fallback because they cannot call the +Session sandboxes, including snapshot restores, get GitHub CLI credentials through the helper rather +than spawn-time token injection; a user-supplied `GH_TOKEN` or `GITHUB_TOKEN` takes precedence. +One-shot image-build sandboxes use only the narrower `VCS_CLONE_TOKEN` because they cannot call the control-plane credential broker. For git operations, the helper keeps the existing installation-wide access model and can authenticate auxiliary private repos on the configured SCM host. diff --git a/packages/modal-infra/.env.example b/packages/modal-infra/.env.example index a680d74ec2..a1bf251c80 100644 --- a/packages/modal-infra/.env.example +++ b/packages/modal-infra/.env.example @@ -6,7 +6,6 @@ # from the control plane's secret store instead. The llm-api-keys secret itself # must exist, because Modal cannot hold a secret with no keys at all: # modal secret create llm-api-keys ANTHROPIC_API_KEY="" -# modal secret create github-app GITHUB_APP_ID="..." GITHUB_APP_PRIVATE_KEY="..." GITHUB_APP_INSTALLATION_ID="..." # modal secret create internal-api MODAL_API_SECRET="..." ALLOWED_CONTROL_PLANE_HOSTS="..." # ============================================================================= @@ -21,15 +20,6 @@ # From: https://console.anthropic.com/ ANTHROPIC_API_KEY= -# ============================================================================= -# GitHub App Credentials (configured as Modal secret "github-app") -# ============================================================================= -# Create at: https://github.com/settings/apps -# Convert private key to PKCS#8: openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in key.pem -out key-pkcs8.pem -GITHUB_APP_ID= -GITHUB_APP_PRIVATE_KEY= -GITHUB_APP_INSTALLATION_ID= - # ============================================================================= # Internal API Secret (configured as Modal secret "internal-api") # ============================================================================= diff --git a/packages/modal-infra/README.md b/packages/modal-infra/README.md index 9efcdb796e..8d3461b4f0 100644 --- a/packages/modal-infra/README.md +++ b/packages/modal-infra/README.md @@ -53,9 +53,11 @@ The in-sandbox runtime (entrypoint supervisor, control-plane bridge, shared type Provided by `packages/sandbox-runtime/src/sandbox_runtime/auth/`: -- **github_app.py**: GitHub App token generation for repo access - **internal.py**: HMAC authentication for control plane requests +Modal holds no source-control credentials. Session sandboxes fetch git credentials on demand from the +control plane; image builds receive a one-shot clone token in the build request. + ### API (`src/`) - **web_api.py**: HTTP endpoints called by the control plane @@ -81,12 +83,6 @@ snapshot, terminate, and delete provider operations. # instead. The secret itself must exist; Modal cannot hold one with no keys. modal secret create llm-api-keys ANTHROPIC_API_KEY="sk-ant-..." -# GitHub App credentials (for repo access) -modal secret create github-app \ - GITHUB_APP_ID="123456" \ - GITHUB_APP_PRIVATE_KEY="$(cat private-key-pkcs8.pem)" \ - GITHUB_APP_INSTALLATION_ID="12345678" - # Internal API secret (for control plane authentication) modal secret create internal-api \ MODAL_API_SECRET="$(openssl rand -hex 32)" \ @@ -145,6 +141,10 @@ Endpoint URLs follow the pattern: `https://{workspace}--open-inspect-{endpoint}. | `api-snapshot-build-sandbox` | POST | Yes | Snapshot the exact tagged build sandbox | | `api-terminate-build-sandbox` | POST | Yes | Terminate the exact tagged build sandbox (idempotent when already absent) | +`api-create-sandbox`, `api-restore-sandbox`, and `api-create-build-sandbox` require `clone_host` and +`clone_username`. The control plane resolves them from its `SCM_PROVIDER`, and Modal sets them as +`VCS_HOST` and `VCS_CLONE_USERNAME` in the sandbox. + ### Example: Create Sandbox ```bash @@ -156,7 +156,9 @@ curl -X POST "https://${WORKSPACE}--open-inspect-api-create-sandbox.modal.run" \ "repo_owner": "your-org", "repo_name": "your-repo", "control_plane_url": "https://your-control-plane.workers.dev", - "sandbox_auth_token": "your-token" + "sandbox_auth_token": "your-token", + "clone_host": "github.com", + "clone_username": "x-access-token" }' ``` @@ -174,9 +176,6 @@ Set via Modal secrets: | Variable | Secret | Description | |----------|--------|-------------| | `ANTHROPIC_API_KEY` | `llm-api-keys` | Anthropic API key for Claude; may be empty when sessions use other providers | -| `GITHUB_APP_ID` | `github-app` | GitHub App ID for repo access | -| `GITHUB_APP_PRIVATE_KEY` | `github-app` | GitHub App private key (PKCS#8) | -| `GITHUB_APP_INSTALLATION_ID` | `github-app` | GitHub App installation ID | | `MODAL_API_SECRET` | `internal-api` | Shared secret for control plane auth | | `ALLOWED_CONTROL_PLANE_HOSTS` | `internal-api` | Comma-separated allowed hostnames for URL validation | diff --git a/packages/modal-infra/pyproject.toml b/packages/modal-infra/pyproject.toml index d9652c4c2c..7fa1bd31cf 100644 --- a/packages/modal-infra/pyproject.toml +++ b/packages/modal-infra/pyproject.toml @@ -9,7 +9,6 @@ dependencies = [ "httpx>=0.27.0", "pydantic>=2.0", "fastapi>=0.110.0", - "PyJWT[crypto]>=2.14.0", ] [project.optional-dependencies] diff --git a/packages/modal-infra/src/app.py b/packages/modal-infra/src/app.py index be68bc51ef..c0052b3059 100644 --- a/packages/modal-infra/src/app.py +++ b/packages/modal-infra/src/app.py @@ -34,27 +34,17 @@ "httpx", "fastapi", "modal", # Required for sandbox.manager imports - "PyJWT[crypto]", # For GitHub App token generation ) # Bundle sandbox_runtime so modal-infra shims can import from it at runtime .add_local_dir(str(_SANDBOX_RUNTIME_DIR), remote_path="/root/sandbox_runtime", copy=True) .env(deployed_image_environment()) ) -# Secrets for GitHub App - used for git operations (clone, push) -# These are used to generate installation tokens, NOT injected into sandboxes -github_app_secrets = modal.Secret.from_name( - "github-app", - required_keys=["GITHUB_APP_ID", "GITHUB_APP_PRIVATE_KEY", "GITHUB_APP_INSTALLATION_ID"], -) - # Secret for internal API authentication and deployment configuration. # Required keys: # MODAL_API_SECRET: verify requests from control plane to Modal endpoints # Optional keys (add to the same secret as needed): # ALLOWED_CONTROL_PLANE_HOSTS: comma-separated list of permitted callback hosts -# SCM_PROVIDER: "github" (default) or "gitlab" — selects the clone credential type -# GITLAB_ACCESS_TOKEN: GitLab PAT used as clone credential when SCM_PROVIDER=gitlab internal_api_secret = modal.Secret.from_name( "internal-api", required_keys=["MODAL_API_SECRET"], diff --git a/packages/modal-infra/src/clone_token.py b/packages/modal-infra/src/clone_token.py deleted file mode 100644 index bfeddfcbaa..0000000000 --- a/packages/modal-infra/src/clone_token.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Resolve VCS clone tokens for Modal sandbox git operations.""" - -import os - -from .log_config import get_logger - -log = get_logger("clone_token") - - -def resolve_clone_token() -> str | None: - """Return a provider-specific clone token, or None when credentials are unavailable.""" - from sandbox_runtime.auth import generate_installation_token - - scm_provider = os.environ.get("SCM_PROVIDER", "github") - - if scm_provider == "gitlab": - token = os.environ.get("GITLAB_ACCESS_TOKEN") - if not token: - log.warn("gitlab.token_missing") - return token - - try: - app_id = os.environ.get("GITHUB_APP_ID") - private_key = os.environ.get("GITHUB_APP_PRIVATE_KEY") - installation_id = os.environ.get("GITHUB_APP_INSTALLATION_ID") - - if app_id and private_key and installation_id: - return generate_installation_token( - app_id=app_id, - private_key=private_key, - installation_id=installation_id, - ) - except Exception as e: - log.warn("github.token_error", exc=e) - - return None diff --git a/packages/modal-infra/src/sandbox/build_session.py b/packages/modal-infra/src/sandbox/build_session.py index 169a96c9a4..4d0651fd03 100644 --- a/packages/modal-infra/src/sandbox/build_session.py +++ b/packages/modal-infra/src/sandbox/build_session.py @@ -81,9 +81,9 @@ async def create( repositories: list[dict], callback_url: str, failure_callback_url: str, + clone_host: str, + clone_username: str, clone_token: str = "", - clone_host: str | None = None, - clone_username: str | None = None, user_env_vars: dict[str, str] | None = None, build_execution_timeout_seconds: int = DEFAULT_BUILD_TIMEOUT_SECONDS, timeout_seconds: int = DEFAULT_BUILD_TIMEOUT_SECONDS, @@ -120,9 +120,9 @@ async def create( ) inject_vcs_env_vars( env_vars, - clone_token or None, clone_host=clone_host, clone_username=clone_username, + clone_token=clone_token, ) command = ("python", "-m", "sandbox_runtime.entrypoint", MODAL_IMAGE_BUILD_START_ARGUMENT) diff --git a/packages/modal-infra/src/sandbox/launch.py b/packages/modal-infra/src/sandbox/launch.py index 7ef0113d16..55522fc615 100644 --- a/packages/modal-infra/src/sandbox/launch.py +++ b/packages/modal-infra/src/sandbox/launch.py @@ -77,7 +77,6 @@ class RepositoryImageSource: @dataclass(frozen=True) class SnapshotImageSource: image_id: str - clone_token: str | None type SandboxImageSource = BaseImageSource | RepositoryImageSource | SnapshotImageSource @@ -158,8 +157,6 @@ async def launch(self, spec: SandboxLaunchSpec) -> SandboxHandle: } ) - clone_token: str | None = None - include_github_cli_aliases = False snapshot_id: str | None = None if isinstance(spec.source, BaseImageSource): image = docker_base_image() if docker.enabled else base_image @@ -173,8 +170,6 @@ async def launch(self, spec: SandboxLaunchSpec) -> SandboxHandle: else: image = modal.Image.from_id(spec.source.image_id) env_vars["RESTORED_FROM_SNAPSHOT"] = "true" - clone_token = spec.source.clone_token - include_github_cli_aliases = True snapshot_id = spec.source.image_id if config.session_config is not None: @@ -186,8 +181,8 @@ async def launch(self, spec: SandboxLaunchSpec) -> SandboxHandle: inject_vcs_env_vars( env_vars, - clone_token=clone_token if has_repository else None, - include_github_cli_aliases=include_github_cli_aliases, + clone_host=config.clone_host, + clone_username=config.clone_username, ) code_server_password: str | None = None diff --git a/packages/modal-infra/src/sandbox/manager.py b/packages/modal-infra/src/sandbox/manager.py index 7b30154a6a..370f08ddeb 100644 --- a/packages/modal-infra/src/sandbox/manager.py +++ b/packages/modal-infra/src/sandbox/manager.py @@ -306,10 +306,12 @@ async def restore_from_snapshot( self, snapshot_image_id: str, session_config: SessionConfig | dict[str, Any], + *, + clone_host: str, + clone_username: str, sandbox_id: str | None = None, control_plane_url: str = "", sandbox_auth_token: str = "", - clone_token: str | None = None, user_env_vars: dict[str, str] | None = None, timeout_seconds: int = DEFAULT_SANDBOX_TIMEOUT_SECONDS, code_server_enabled: bool = False, @@ -332,7 +334,8 @@ async def restore_from_snapshot( sandbox_id: Optional sandbox ID (generated if not provided) control_plane_url: URL for the control plane sandbox_auth_token: Auth token for the sandbox - clone_token: VCS clone token for git operations + clone_host: VCS host resolved by the control plane + clone_username: VCS clone username resolved by the control plane Returns: SandboxHandle for the restored sandbox @@ -347,14 +350,6 @@ async def restore_from_snapshot( repo_name = session_config.repo_name _has_repository(repo_owner, repo_name) - # Snapshot restore still passes the clone token through for - # repo-backed sandboxes. Snapshots taken before the credential-helper - # migration ship an entrypoint that reads VCS_CLONE_TOKEN from env - # and embeds it in the origin URL; without it, those legacy snapshots - # can't fetch. GITHUB_TOKEN/GITHUB_APP_TOKEN aliases are restored too - # so the gh CLI keeps working on snapshots predating the gh wrapper. - # Host scoping remains common with fresh creates. These compatibility - # credentials are explicitly requested only by the restore path. handle = await SandboxLauncher().launch( SandboxLaunchSpec( config=SandboxConfig( @@ -373,11 +368,10 @@ async def restore_from_snapshot( retire_sandbox_id=retire_sandbox_id, sandbox_backend=sandbox_backend, launch_deadline_at_ms=launch_deadline_at_ms, + clone_host=clone_host, + clone_username=clone_username, ), - source=SnapshotImageSource( - image_id=snapshot_image_id, - clone_token=clone_token, - ), + source=SnapshotImageSource(image_id=snapshot_image_id), ) ) diff --git a/packages/modal-infra/src/sandbox/models.py b/packages/modal-infra/src/sandbox/models.py index 61125ecaa1..8a0a11fb09 100644 --- a/packages/modal-infra/src/sandbox/models.py +++ b/packages/modal-infra/src/sandbox/models.py @@ -15,10 +15,12 @@ @dataclass class SandboxConfig: - """Configuration for creating a sandbox.""" + """Shared configuration for creating or restoring a sandbox.""" repo_owner: str | None repo_name: str | None + clone_host: str # VCS identity resolved by the control plane + clone_username: str sandbox_id: str | None = None # Expected sandbox ID from control plane session_config: SessionConfig | dict[str, Any] | None = None control_plane_url: str = "" diff --git a/packages/modal-infra/src/sandbox/vcs_env.py b/packages/modal-infra/src/sandbox/vcs_env.py index 21be896ff8..d809132c2d 100644 --- a/packages/modal-infra/src/sandbox/vcs_env.py +++ b/packages/modal-infra/src/sandbox/vcs_env.py @@ -1,40 +1,15 @@ """SCM credential environment shared by interactive and build sandboxes.""" -import os - def inject_vcs_env_vars( env_vars: dict[str, str], - clone_token: str | None, *, - clone_host: str | None = None, - clone_username: str | None = None, - include_github_cli_aliases: bool = False, + clone_host: str, + clone_username: str, + clone_token: str | None = None, ) -> None: - """Inject provider metadata and optional one-shot clone credentials.""" - scm_provider = os.environ.get("SCM_PROVIDER", "github") - if clone_host and clone_username: - env_vars["VCS_HOST"] = clone_host - env_vars["VCS_CLONE_USERNAME"] = clone_username - elif scm_provider == "bitbucket": - env_vars["VCS_HOST"] = "bitbucket.org" - env_vars["VCS_CLONE_USERNAME"] = "x-token-auth" - elif scm_provider == "gitlab": - env_vars["VCS_HOST"] = "gitlab.com" - env_vars["VCS_CLONE_USERNAME"] = "oauth2" - else: - env_vars["VCS_HOST"] = "github.com" - env_vars["VCS_CLONE_USERNAME"] = "x-access-token" - - if not clone_token: - return - - env_vars["VCS_CLONE_TOKEN"] = clone_token - if include_github_cli_aliases and scm_provider == "github": - has_user_github_cli_token = any( - env_vars.get(key) for key in ("GH_TOKEN", "GITHUB_TOKEN", "GITHUB_APP_TOKEN") - ) - if not has_user_github_cli_token: - env_vars["GITHUB_TOKEN"] = clone_token - env_vars["GITHUB_APP_TOKEN"] = clone_token - env_vars["OI_GITHUB_TOKEN_IS_FALLBACK"] = "1" + """Inject the control plane's VCS identity and optional one-shot clone token.""" + env_vars["VCS_HOST"] = clone_host + env_vars["VCS_CLONE_USERNAME"] = clone_username + if clone_token: + env_vars["VCS_CLONE_TOKEN"] = clone_token diff --git a/packages/modal-infra/src/web_api.py b/packages/modal-infra/src/web_api.py index 16e3677a52..15b99a3c74 100644 --- a/packages/modal-infra/src/web_api.py +++ b/packages/modal-infra/src/web_api.py @@ -31,11 +31,9 @@ from .app import ( app, function_image, - github_app_secrets, internal_api_secret, validate_control_plane_url, ) -from .clone_token import resolve_clone_token from .log_config import configure_logging, get_logger from .sandbox.launch_policy import ( DockerImageUnavailableError, @@ -78,8 +76,8 @@ class CreateBuildSandboxRequest(_ModalRequestModel): callback_url: NonEmptyString failure_callback_url: NonEmptyString clone_token: str | None = None - clone_host: str | None = None - clone_username: str | None = None + clone_host: NonEmptyString + clone_username: NonEmptyString user_env_vars: dict[str, str] | None = None build_execution_timeout_seconds: int | None = None provider_session_timeout_seconds: int | None = None @@ -127,6 +125,8 @@ class CreateSandboxRequest(_RepositoryContextModel): sandbox_id: str | None = None control_plane_url: NonEmptyString sandbox_auth_token: NonEmptyString + clone_host: NonEmptyString + clone_username: NonEmptyString agent_session_id: str | None = None opencode_session_id: str | None = None harness: str | None = None @@ -174,6 +174,8 @@ class RestoreSandboxRequest(_ModalRequestModel): sandbox_id: str | None = None control_plane_url: NonEmptyString sandbox_auth_token: NonEmptyString + clone_host: NonEmptyString + clone_username: NonEmptyString user_env_vars: dict[str, str] | None = None timeout_seconds: int | None = Field(default=None, gt=0) code_server_enabled: bool = False @@ -420,6 +422,8 @@ async def api_create_sandbox( "repo_name": "...", "control_plane_url": "...", "sandbox_auth_token": "...", + "clone_host": "github.com", + "clone_username": "x-access-token", "provider": "anthropic", "model": "claude-sonnet-4-6" } @@ -457,6 +461,8 @@ async def api_create_sandbox( session_config=session_config, control_plane_url=parsed_request.control_plane_url, sandbox_auth_token=parsed_request.sandbox_auth_token, + clone_host=parsed_request.clone_host, + clone_username=parsed_request.clone_username, user_env_vars=parsed_request.user_env_vars or None, repo_image_id=parsed_request.repo_image_id or None, repo_image_sha=parsed_request.repo_image_sha or None, @@ -791,7 +797,7 @@ async def api_snapshot_build_sandbox( } -@app.function(image=function_image, secrets=[github_app_secrets, internal_api_secret], timeout=150) +@app.function(image=function_image, secrets=[internal_api_secret], timeout=150) @fastapi_endpoint(method="POST") async def api_restore_sandbox( request: dict, @@ -822,7 +828,9 @@ async def api_restore_sandbox( }, "sandbox_id": "...", "control_plane_url": "...", - "sandbox_auth_token": "..." + "sandbox_auth_token": "...", + "clone_host": "github.com", + "clone_username": "x-access-token" } Returns: @@ -852,11 +860,8 @@ async def api_restore_sandbox( ) session_config = parsed_request.session_config.model_dump(exclude_unset=True) - repo_owner = parsed_request.session_config.repo_owner - repo_name = parsed_request.session_config.repo_name manager = SandboxManager() - clone_token = resolve_clone_token() if repo_owner and repo_name else None # Restore sandbox from snapshot handle = await manager.restore_from_snapshot( @@ -865,7 +870,8 @@ async def api_restore_sandbox( sandbox_id=parsed_request.sandbox_id, control_plane_url=parsed_request.control_plane_url, sandbox_auth_token=parsed_request.sandbox_auth_token, - clone_token=clone_token, + clone_host=parsed_request.clone_host, + clone_username=parsed_request.clone_username, user_env_vars=parsed_request.user_env_vars or None, timeout_seconds=( parsed_request.timeout_seconds @@ -948,8 +954,6 @@ async def api_create_build_sandbox( ), max_seconds=MAX_BUILD_TIMEOUT_SECONDS + IMAGE_BUILD_FINALIZATION_GRACE_SECONDS, ) - clone_host = parsed_request.clone_host or None - clone_username = parsed_request.clone_username or None callback_url = parsed_request.callback_url failure_callback_url = parsed_request.failure_callback_url if not validate_control_plane_url(callback_url) or not validate_control_plane_url( @@ -966,9 +970,9 @@ async def api_create_build_sandbox( repositories=repositories, callback_url=callback_url, failure_callback_url=failure_callback_url, + clone_host=parsed_request.clone_host, + clone_username=parsed_request.clone_username, clone_token=parsed_request.clone_token or "", - clone_host=clone_host, - clone_username=clone_username, user_env_vars=parsed_request.user_env_vars or None, build_execution_timeout_seconds=build_execution_timeout_seconds, timeout_seconds=provider_session_timeout_seconds, diff --git a/packages/modal-infra/tests/test_agent_slack_notify_env.py b/packages/modal-infra/tests/test_agent_slack_notify_env.py index ede0a3c971..3a46f9c432 100644 --- a/packages/modal-infra/tests/test_agent_slack_notify_env.py +++ b/packages/modal-infra/tests/test_agent_slack_notify_env.py @@ -40,6 +40,8 @@ async def test_env_set_when_enabled(self, monkeypatch): manager = SandboxManager() config = SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", control_plane_url="https://cp.example.com", @@ -58,6 +60,8 @@ async def test_env_omitted_when_disabled(self, monkeypatch): manager = SandboxManager() config = SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", control_plane_url="https://cp.example.com", @@ -85,6 +89,8 @@ class FakeImage: manager = SandboxManager() await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-123", session_config={"repo_owner": "acme", "repo_name": "repo"}, sandbox_id="sb-1", diff --git a/packages/modal-infra/tests/test_build_sandbox_lifecycle.py b/packages/modal-infra/tests/test_build_sandbox_lifecycle.py index 9cd465d150..9e306f668f 100644 --- a/packages/modal-infra/tests/test_build_sandbox_lifecycle.py +++ b/packages/modal-infra/tests/test_build_sandbox_lifecycle.py @@ -447,6 +447,8 @@ async def test_create_build_sandbox_selects_the_variant_from_frozen_settings( monkeypatch.setattr("src.images.base.docker_image", docker_image) launch = await ModalBuildSessionService().create( + clone_host="github.com", + clone_username="x-access-token", build_id="build-1", scope_kind="repo", scope_id="acme/repo", @@ -493,6 +495,8 @@ async def test_build_create_retry_adopts_only_owned_backend_allocation(monkeypat monkeypatch.setattr("src.sandbox.build_session.modal.Sandbox.create", create) service = ModalBuildSessionService() launch = await service.create( + clone_host="github.com", + clone_username="x-access-token", build_id="build-1", sandbox_backend="modal-vm", scope_kind="repo", @@ -506,6 +510,8 @@ async def test_build_create_retry_adopts_only_owned_backend_allocation(monkeypat tags["openinspect_backend"] = "modal" with pytest.raises(RuntimeError, match="ownership"): await service.create( + clone_host="github.com", + clone_username="x-access-token", build_id="build-1", sandbox_backend="modal-vm", scope_kind="repo", diff --git a/packages/modal-infra/tests/test_clone_token.py b/packages/modal-infra/tests/test_clone_token.py deleted file mode 100644 index 93b3013345..0000000000 --- a/packages/modal-infra/tests/test_clone_token.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Tests for VCS clone token resolution.""" - -import pytest - -from src.clone_token import resolve_clone_token - - -@pytest.fixture(autouse=True) -def clear_clone_token_env(monkeypatch: pytest.MonkeyPatch) -> None: - for name in [ - "SCM_PROVIDER", - "GITLAB_ACCESS_TOKEN", - "GITHUB_APP_ID", - "GITHUB_APP_PRIVATE_KEY", - "GITHUB_APP_INSTALLATION_ID", - ]: - monkeypatch.delenv(name, raising=False) - - -def test_resolve_clone_token_uses_gitlab_access_token(monkeypatch): - monkeypatch.setenv("SCM_PROVIDER", "gitlab") - monkeypatch.setenv("GITLAB_ACCESS_TOKEN", "glpat-token") - - assert resolve_clone_token() == "glpat-token" - - -def test_resolve_clone_token_returns_none_for_missing_gitlab_token(monkeypatch): - monkeypatch.setenv("SCM_PROVIDER", "gitlab") - - assert resolve_clone_token() is None - - -def test_resolve_clone_token_generates_github_installation_token(monkeypatch): - monkeypatch.setenv("GITHUB_APP_ID", "123") - monkeypatch.setenv("GITHUB_APP_PRIVATE_KEY", "private-key") - monkeypatch.setenv("GITHUB_APP_INSTALLATION_ID", "456") - captured = {} - - def fake_generate_installation_token(**kwargs): - captured.update(kwargs) - return "ghs-token" - - monkeypatch.setattr( - "sandbox_runtime.auth.generate_installation_token", fake_generate_installation_token - ) - - assert resolve_clone_token() == "ghs-token" - assert captured == { - "app_id": "123", - "private_key": "private-key", - "installation_id": "456", - } - - -def test_resolve_clone_token_returns_none_when_github_credentials_incomplete(monkeypatch): - monkeypatch.setenv("GITHUB_APP_ID", "123") - monkeypatch.setenv("GITHUB_APP_INSTALLATION_ID", "456") - - def fail_if_called(**_kwargs): - raise AssertionError("generate_installation_token should not be called") - - monkeypatch.setattr("sandbox_runtime.auth.generate_installation_token", fail_if_called) - - assert resolve_clone_token() is None - - -def test_resolve_clone_token_returns_none_when_github_generation_fails(monkeypatch): - monkeypatch.setenv("GITHUB_APP_ID", "123") - monkeypatch.setenv("GITHUB_APP_PRIVATE_KEY", "private-key") - monkeypatch.setenv("GITHUB_APP_INSTALLATION_ID", "456") - - def raise_from_generate(**_kwargs): - raise RuntimeError("token generation failed") - - monkeypatch.setattr("sandbox_runtime.auth.generate_installation_token", raise_from_generate) - - assert resolve_clone_token() is None diff --git a/packages/modal-infra/tests/test_code_server.py b/packages/modal-infra/tests/test_code_server.py index 302d8c7675..36adbf7ccc 100644 --- a/packages/modal-infra/tests/test_code_server.py +++ b/packages/modal-infra/tests/test_code_server.py @@ -108,6 +108,8 @@ class FakeSandbox: manager = SandboxManager() config = SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", control_plane_url="https://cp.example.com", @@ -149,6 +151,8 @@ class FakeSandbox: manager = SandboxManager() config = SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", control_plane_url="https://cp.example.com", @@ -199,6 +203,8 @@ class FakeSandbox: manager = SandboxManager() handle = await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-abc", session_config={ "repo_owner": "acme", @@ -247,6 +253,8 @@ class FakeSandbox: manager = SandboxManager() handle = await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-abc", session_config={ "repo_owner": "acme", diff --git a/packages/modal-infra/tests/test_llm_secrets.py b/packages/modal-infra/tests/test_llm_secrets.py index a6448ecc7b..4bd037392c 100644 --- a/packages/modal-infra/tests/test_llm_secrets.py +++ b/packages/modal-infra/tests/test_llm_secrets.py @@ -31,7 +31,14 @@ class FakeSandbox: async def test_create_attaches_the_deployment_wide_secret(captured_launch, fake_llm_secret): - await SandboxManager().create_sandbox(SandboxConfig(repo_owner="acme", repo_name="repo")) + await SandboxManager().create_sandbox( + SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", + repo_owner="acme", + repo_name="repo", + ) + ) assert len(fake_llm_secret) == 1 assert captured_launch["secrets"] == fake_llm_secret @@ -47,6 +54,8 @@ class FakeImage: monkeypatch.setattr("src.sandbox.launch.modal.Image.from_id", lambda *a, **k: FakeImage()) await SandboxManager().restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-abc", session_config={"repo_owner": "acme", "repo_name": "repo", "session_id": "sess-1"}, ) @@ -57,7 +66,12 @@ class FakeImage: async def test_each_launch_resolves_a_fresh_secret(captured_launch, fake_llm_secret): - config = SandboxConfig(repo_owner="acme", repo_name="repo") + config = SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", + repo_owner="acme", + repo_name="repo", + ) await SandboxManager().create_sandbox(config) first_secret = captured_launch["secrets"][0] await SandboxManager().create_sandbox(config) diff --git a/packages/modal-infra/tests/test_sandbox_env_vars.py b/packages/modal-infra/tests/test_sandbox_env_vars.py index be4130a938..46dda9b3e6 100644 --- a/packages/modal-infra/tests/test_sandbox_env_vars.py +++ b/packages/modal-infra/tests/test_sandbox_env_vars.py @@ -52,7 +52,14 @@ async def test_create_sandbox_rejects_partial_repo_metadata(repo_owner, repo_nam manager = SandboxManager() with pytest.raises(ValueError, match="repo_owner and repo_name must be provided together"): - await manager.create_sandbox(SandboxConfig(repo_owner=repo_owner, repo_name=repo_name)) + await manager.create_sandbox( + SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", + repo_owner=repo_owner, + repo_name=repo_name, + ) + ) @pytest.mark.asyncio @@ -68,6 +75,8 @@ async def test_restore_rejects_partial_repo_metadata(session_config): with pytest.raises(ValueError, match="repo_owner and repo_name must be provided together"): await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-abc", session_config=session_config, ) @@ -91,6 +100,8 @@ class FakeSandbox: manager = SandboxManager() config = SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", control_plane_url="https://control-plane.example", @@ -138,6 +149,8 @@ class FakeSandbox: manager = SandboxManager() await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-abc", session_config={ "repo_owner": "acme", @@ -184,6 +197,8 @@ async def test_create_preserves_managed_provider_env_isolation( await SandboxManager().create_sandbox( SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", user_env_vars={managed_marker: "1", "CUSTOM_SECRET": "value"}, @@ -208,6 +223,8 @@ async def test_restore_preserves_managed_provider_env_isolation( captured = _fake_restore_setup(monkeypatch) await SandboxManager().restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-abc", session_config={"session_id": "sess-1"}, user_env_vars={managed_marker: "1", "CUSTOM_SECRET": "value"}, @@ -247,6 +264,8 @@ class FakeSandbox: manager = SandboxManager() await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-abc", session_config={ "repo_owner": "acme", @@ -287,6 +306,8 @@ class FakeSandbox: manager = SandboxManager() await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-abc", session_config={ "repo_owner": "acme", @@ -335,6 +356,8 @@ class FakeSandbox: # Create with custom timeout config = SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", timeout_seconds=5400, @@ -343,6 +366,8 @@ class FakeSandbox: # Restore with same timeout await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-abc", session_config={ "repo_owner": "acme", @@ -382,6 +407,8 @@ async def test_restore_includes_branch_in_session_config(monkeypatch): manager = SandboxManager() await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-abc", session_config={ "repo_owner": "acme", @@ -404,6 +431,8 @@ async def test_restore_omits_branch_when_none(monkeypatch): manager = SandboxManager() await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-abc", session_config={ "repo_owner": "acme", @@ -423,6 +452,8 @@ async def test_restore_serializes_typed_session_config(monkeypatch): captured = _fake_restore_setup(monkeypatch) await SandboxManager().restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-abc", session_config=SessionConfig( session_id="sess-1", @@ -459,98 +490,37 @@ class FakeSandbox: return fake_create_aio -# Note: fresh and repo-image sandboxes never receive SCM tokens in the -# environment; created sandboxes rely on brokered credentials only. - +_SYSTEM_TOKEN_KEYS = ("VCS_CLONE_TOKEN", "GITHUB_TOKEN", "GITHUB_APP_TOKEN", "GH_TOKEN") -@pytest.mark.asyncio -async def test_vcs_env_vars_default_github(monkeypatch): - """SCM_PROVIDER unset → github.com defaults, no token in env.""" - captured = {} - monkeypatch.setattr("src.sandbox.launch.modal.Sandbox.create", _fake_sandbox_create(captured)) - monkeypatch.delenv("SCM_PROVIDER", raising=False) - manager = SandboxManager() - config = SandboxConfig( - repo_owner="acme", - repo_name="repo", - ) - await manager.create_sandbox(config) - - env = captured["env"] - assert env["VCS_HOST"] == "github.com" - assert env["VCS_CLONE_USERNAME"] == "x-access-token" - assert "VCS_CLONE_TOKEN" not in env - assert "GITHUB_APP_TOKEN" not in env - assert "GITHUB_TOKEN" not in env - - -@pytest.mark.asyncio -async def test_vcs_env_vars_gitlab(monkeypatch): - """SCM_PROVIDER=gitlab → gitlab.com + oauth2, no token in env.""" - captured = {} - monkeypatch.setattr("src.sandbox.launch.modal.Sandbox.create", _fake_sandbox_create(captured)) - monkeypatch.setenv("SCM_PROVIDER", "gitlab") - - manager = SandboxManager() - config = SandboxConfig( - repo_owner="acme", - repo_name="repo", - ) - await manager.create_sandbox(config) - - env = captured["env"] - assert env["VCS_HOST"] == "gitlab.com" +def _assert_identity_without_system_tokens(env: dict[str, str]) -> None: + assert env["VCS_HOST"] == "gitlab.example" assert env["VCS_CLONE_USERNAME"] == "oauth2" - assert "VCS_CLONE_TOKEN" not in env + for key in _SYSTEM_TOKEN_KEYS: + assert key not in env @pytest.mark.asyncio -async def test_vcs_env_vars_bitbucket(monkeypatch): - """SCM_PROVIDER=bitbucket → bitbucket.org + x-token-auth, no token in env.""" - captured = {} - monkeypatch.setattr("src.sandbox.launch.modal.Sandbox.create", _fake_sandbox_create(captured)) - monkeypatch.setenv("SCM_PROVIDER", "bitbucket") - - manager = SandboxManager() - config = SandboxConfig( - repo_owner="acme", - repo_name="repo", - ) - await manager.create_sandbox(config) - - env = captured["env"] - assert env["VCS_HOST"] == "bitbucket.org" - assert env["VCS_CLONE_USERNAME"] == "x-token-auth" - assert "VCS_CLONE_TOKEN" not in env - - -@pytest.mark.asyncio -async def test_repo_image_boot_omits_fallback_tokens(monkeypatch): - """Repo-image boots rely on brokered credentials only.""" +@pytest.mark.parametrize( + "repo_fields", + [ + {"repo_owner": "acme", "repo_name": "repo"}, + {"repo_owner": "acme", "repo_name": "repo", "repo_image_id": "repo-img-1"}, + {"repo_owner": None, "repo_name": None}, + ], + ids=["base", "repo-image", "no-repo"], +) +async def test_create_injects_control_plane_identity_without_tokens(monkeypatch, repo_fields): + """Created sandboxes get the supplied VCS identity and rely on brokered credentials.""" captured = {} - - class FakeImage: - object_id = "repo-img-1" - - monkeypatch.setattr("src.sandbox.launch.modal.Image.from_id", lambda *a, **kw: FakeImage()) + monkeypatch.setattr("src.sandbox.launch.modal.Image.from_id", lambda *a, **kw: object()) monkeypatch.setattr("src.sandbox.launch.modal.Sandbox.create", _fake_sandbox_create(captured)) - monkeypatch.delenv("SCM_PROVIDER", raising=False) - manager = SandboxManager() - config = SandboxConfig( - repo_owner="acme", - repo_name="repo", - repo_image_id="repo-img-1", + await SandboxManager().create_sandbox( + SandboxConfig(clone_host="gitlab.example", clone_username="oauth2", **repo_fields) ) - await manager.create_sandbox(config) - env = captured["env"] - assert env["FROM_REPO_IMAGE"] == "true" - assert "VCS_CLONE_TOKEN" not in env - assert "GITHUB_TOKEN" not in env - assert "GITHUB_APP_TOKEN" not in env - assert "OI_GITHUB_TOKEN_IS_FALLBACK" not in env + _assert_identity_without_system_tokens(captured["env"]) @pytest.mark.asyncio @@ -558,17 +528,13 @@ class FakeImage: async def test_repo_image_boot_preserves_user_github_cli_token(monkeypatch, token_key): """Repo-image boots do not replace user-provided GitHub CLI tokens.""" captured = {} - - class FakeImage: - object_id = "repo-img-1" - - monkeypatch.setattr("src.sandbox.launch.modal.Image.from_id", lambda *a, **kw: FakeImage()) + monkeypatch.setattr("src.sandbox.launch.modal.Image.from_id", lambda *a, **kw: object()) monkeypatch.setattr("src.sandbox.launch.modal.Sandbox.create", _fake_sandbox_create(captured)) - monkeypatch.delenv("SCM_PROVIDER", raising=False) - manager = SandboxManager() - await manager.create_sandbox( + await SandboxManager().create_sandbox( SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", repo_image_id="repo-img-1", @@ -579,172 +545,64 @@ class FakeImage: env = captured["env"] assert env[token_key] == "user_token" assert "VCS_CLONE_TOKEN" not in env - assert env.get("GITHUB_TOKEN") != "ghs_repo_image_token" - assert env.get("GITHUB_APP_TOKEN") != "ghs_repo_image_token" - assert "OI_GITHUB_TOKEN_IS_FALLBACK" not in env - - -@pytest.mark.asyncio -async def test_no_repo_sandbox_gets_provider_host_scoping(monkeypatch): - """No-repository sandboxes still get provider host scoping. - - Without VCS_HOST, a GitLab/Bitbucket deployment's repo-less sandboxes - fall back to github.com credential-helper behavior. - """ - captured = {} - monkeypatch.setattr("src.sandbox.launch.modal.Sandbox.create", _fake_sandbox_create(captured)) - monkeypatch.setenv("SCM_PROVIDER", "gitlab") - - manager = SandboxManager() - await manager.create_sandbox(SandboxConfig(repo_owner=None, repo_name=None)) - - env = captured["env"] - assert env["VCS_HOST"] == "gitlab.com" - assert env["VCS_CLONE_USERNAME"] == "oauth2" - assert "VCS_CLONE_TOKEN" not in env - - -@pytest.mark.asyncio -async def test_restore_no_repo_gets_host_scoping_without_tokens(monkeypatch): - """No-repository snapshot restores get host scoping but never a clone token.""" - captured = {} - - class FakeImage: - object_id = "img-123" - - monkeypatch.setattr("src.sandbox.launch.modal.Image.from_id", lambda *a, **kw: FakeImage()) - monkeypatch.setattr("src.sandbox.launch.modal.Sandbox.create", _fake_sandbox_create(captured)) - monkeypatch.setenv("SCM_PROVIDER", "bitbucket") - - manager = SandboxManager() - await manager.restore_from_snapshot( - snapshot_image_id="img-abc", - session_config={ - "session_id": "sess-1", - "provider": "anthropic", - "model": "claude-sonnet-4-6", - }, - clone_token="bb_token_xyz", - ) - - env = captured["env"] - assert env["VCS_HOST"] == "bitbucket.org" - assert env["VCS_CLONE_USERNAME"] == "x-token-auth" - assert "VCS_CLONE_TOKEN" not in env - assert "GITHUB_TOKEN" not in env - assert "GITHUB_APP_TOKEN" not in env @pytest.mark.asyncio -async def test_restore_preserves_vcs_clone_token_for_legacy_snapshots(monkeypatch): - """Snapshot restore still injects VCS_CLONE_TOKEN. - - Snapshots taken before the credential-helper migration ship an old - entrypoint that reads the env var and embeds it in the origin URL. - Without it those snapshots can't fetch. The new entrypoint ignores it - and routes through the helper, so the var is harmless on fresh images. - For a non-GitHub provider, the GitHub CLI aliases stay absent. - """ - captured = {} - - class FakeImage: - object_id = "img-123" - - monkeypatch.setattr("src.sandbox.launch.modal.Image.from_id", lambda *a, **kw: FakeImage()) - monkeypatch.setattr("src.sandbox.launch.modal.Sandbox.create", _fake_sandbox_create(captured)) - monkeypatch.setenv("SCM_PROVIDER", "bitbucket") - - manager = SandboxManager() - await manager.restore_from_snapshot( - snapshot_image_id="img-abc", - session_config={ - "repo_owner": "acme", - "repo_name": "repo", - "provider": "anthropic", - "model": "claude-sonnet-4-6", - "session_id": "sess-1", - }, - clone_token="bb_token_xyz", - ) - - env = captured["env"] - assert env["VCS_HOST"] == "bitbucket.org" - assert env["VCS_CLONE_USERNAME"] == "x-token-auth" - assert env["VCS_CLONE_TOKEN"] == "bb_token_xyz" - assert "GITHUB_APP_TOKEN" not in env - assert "GITHUB_TOKEN" not in env - - -@pytest.mark.asyncio -async def test_restore_github_includes_gh_cli_aliases(monkeypatch): - """On GitHub, snapshot restore also sets GITHUB_TOKEN/GITHUB_APP_TOKEN. - - Legacy snapshots lack the gh wrapper, so the CLI needs the token in env. - """ +@pytest.mark.parametrize( + "repo_fields", + [{"repo_owner": "acme", "repo_name": "repo"}, {"repo_owner": None, "repo_name": None}], + ids=["repo", "no-repo"], +) +async def test_restore_injects_control_plane_identity_without_system_tokens( + monkeypatch, repo_fields +): + """Restores never forward function-level system credentials into the sandbox.""" captured = {} - - class FakeImage: - object_id = "img-123" - - monkeypatch.setattr("src.sandbox.launch.modal.Image.from_id", lambda *a, **kw: FakeImage()) + monkeypatch.setattr("src.sandbox.launch.modal.Image.from_id", lambda *a, **kw: object()) monkeypatch.setattr("src.sandbox.launch.modal.Sandbox.create", _fake_sandbox_create(captured)) - monkeypatch.delenv("SCM_PROVIDER", raising=False) + monkeypatch.setenv("VCS_CLONE_TOKEN", "system-clone-token") + monkeypatch.setenv("GITLAB_ACCESS_TOKEN", "system-gitlab-token") + monkeypatch.setenv("GITHUB_TOKEN", "system-github-token") + monkeypatch.setenv("GITHUB_APP_PRIVATE_KEY", "system-private-key") - manager = SandboxManager() - await manager.restore_from_snapshot( + await SandboxManager().restore_from_snapshot( snapshot_image_id="img-abc", - session_config={ - "repo_owner": "acme", - "repo_name": "repo", - "provider": "anthropic", - "model": "claude-sonnet-4-6", - "session_id": "sess-1", - }, - clone_token="ghs_restore_token", + session_config={"session_id": "sess-1", **repo_fields}, + clone_host="gitlab.example", + clone_username="oauth2", ) env = captured["env"] - assert env["VCS_HOST"] == "github.com" - assert env["VCS_CLONE_TOKEN"] == "ghs_restore_token" - assert env["GITHUB_TOKEN"] == "ghs_restore_token" - assert env["GITHUB_APP_TOKEN"] == "ghs_restore_token" - # Marked so the gh wrapper on helper-capable snapshots refreshes past it - # instead of reusing the soon-expired restore token. - assert env["OI_GITHUB_TOKEN_IS_FALLBACK"] == "1" + assert env["RESTORED_FROM_SNAPSHOT"] == "true" + assert env["REPO_OWNER"] == (repo_fields["repo_owner"] or "") + _assert_identity_without_system_tokens(env) + assert "GITLAB_ACCESS_TOKEN" not in env + assert "GITHUB_APP_PRIVATE_KEY" not in env @pytest.mark.asyncio -async def test_no_repo_restore_omits_clone_token(monkeypatch): - """No-repository snapshot restores get host scoping but no VCS credentials.""" +@pytest.mark.parametrize( + "user_token_key", [None, "GH_TOKEN", "GITHUB_TOKEN", "GITHUB_APP_TOKEN", "VCS_CLONE_TOKEN"] +) +async def test_restore_preserves_user_tokens_without_generating_gh_cli_aliases( + monkeypatch, user_token_key +): + """Restore neither strips user tokens nor adds system GitHub CLI aliases.""" captured = {} - - class FakeImage: - object_id = "img-123" - - monkeypatch.setattr("src.sandbox.launch.modal.Image.from_id", lambda *a, **kw: FakeImage()) + monkeypatch.setattr("src.sandbox.launch.modal.Image.from_id", lambda *a, **kw: object()) monkeypatch.setattr("src.sandbox.launch.modal.Sandbox.create", _fake_sandbox_create(captured)) - monkeypatch.delenv("SCM_PROVIDER", raising=False) - manager = SandboxManager() - await manager.restore_from_snapshot( + await SandboxManager().restore_from_snapshot( snapshot_image_id="img-abc", - session_config={ - "repo_owner": None, - "repo_name": None, - "provider": "anthropic", - "model": "claude-sonnet-4-6", - "session_id": "sess-1", - }, - clone_token="ghs_restore_token", + session_config={"session_id": "sess-1", "repo_owner": "acme", "repo_name": "repo"}, + clone_host="github.com", + clone_username="x-access-token", + user_env_vars={user_token_key: "user-token"} if user_token_key else None, ) env = captured["env"] - assert "REPOSITORY_MODE" not in env - assert env["REPO_OWNER"] == "" - assert env["REPO_NAME"] == "" - assert env["VCS_HOST"] == "github.com" - assert env["VCS_CLONE_USERNAME"] == "x-access-token" - assert "VCS_CLONE_TOKEN" not in env - assert "GITHUB_TOKEN" not in env - assert "GITHUB_APP_TOKEN" not in env - assert "OI_GITHUB_TOKEN_IS_FALLBACK" not in env + for key in _SYSTEM_TOKEN_KEYS: + if key == user_token_key: + assert env[key] == "user-token" + else: + assert key not in env diff --git a/packages/modal-infra/tests/test_sandbox_launch.py b/packages/modal-infra/tests/test_sandbox_launch.py index f56fadd258..7753d8f29a 100644 --- a/packages/modal-infra/tests/test_sandbox_launch.py +++ b/packages/modal-infra/tests/test_sandbox_launch.py @@ -70,7 +70,12 @@ async def create_aio(*args, **kwargs): ids=["defaults", "cpu-only", "memory-only", "cpu-and-memory"], ) async def test_launch_matrix_preserves_common_and_source_specific_behavior( - monkeypatch, image_source, resources, expected_cpu, expected_memory, timeout_seconds + monkeypatch, + image_source, + resources, + expected_cpu, + expected_memory, + timeout_seconds, ): captured: dict = {} base_image = object() @@ -81,7 +86,6 @@ async def test_launch_matrix_preserves_common_and_source_specific_behavior( monkeypatch.setattr("src.sandbox.launch.base_image", base_image) monkeypatch.setattr("src.sandbox.launch.modal.Image.from_id", images.__getitem__) monkeypatch.setattr("src.sandbox.launch.modal.Sandbox.create", _fake_create(captured)) - monkeypatch.delenv("SCM_PROVIDER", raising=False) monkeypatch.setattr( SandboxLauncher, "_generate_code_server_password", staticmethod(lambda: "code-password") ) @@ -100,6 +104,8 @@ async def test_launch_matrix_preserves_common_and_source_specific_behavior( "sandbox_id": "sandbox-1", "control_plane_url": "https://control.example", "sandbox_auth_token": "sandbox-token", + "clone_host": "github.example", + "clone_username": "provided-user", "timeout_seconds": timeout_seconds, "user_env_vars": { "CONTROL_PLANE_URL": "https://user.example", @@ -128,7 +134,6 @@ async def test_launch_matrix_preserves_common_and_source_specific_behavior( "repo_name": "repo", "future_field": {"preserved": True}, }, - clone_token="legacy-clone-token", **common, ) expected_image = images["snapshot-image-1"] @@ -176,6 +181,12 @@ async def test_launch_matrix_preserves_common_and_source_specific_behavior( assert env["AGENT_SLACK_NOTIFY_ENABLED"] == "true" assert env["TERMINAL_ENABLED"] == "true" assert "IMAGE_BUILD_MODE" not in env + assert "GITHUB_APP_PRIVATE_KEY" not in env + assert env["VCS_HOST"] == "github.example" + assert env["VCS_CLONE_USERNAME"] == "provided-user" + assert "VCS_CLONE_TOKEN" not in env + assert "GITHUB_TOKEN" not in env + assert "GITHUB_APP_TOKEN" not in env if image_source == "repository": assert env["FROM_REPO_IMAGE"] == "true" @@ -186,12 +197,8 @@ async def test_launch_matrix_preserves_common_and_source_specific_behavior( if image_source == "snapshot": assert env["RESTORED_FROM_SNAPSHOT"] == "true" assert '"future_field": {"preserved": true}' in env["SESSION_CONFIG"] - assert env["VCS_CLONE_TOKEN"] == "legacy-clone-token" - assert env["GITHUB_TOKEN"] == "legacy-clone-token" - assert env["GITHUB_APP_TOKEN"] == "legacy-clone-token" else: assert "RESTORED_FROM_SNAPSHOT" not in env - assert "VCS_CLONE_TOKEN" not in env session_config = json.loads(env["SESSION_CONFIG"]) assert session_config["branch"] == "feature/shared-launch" @@ -218,7 +225,13 @@ async def test_repository_image_create_validates_repo_before_image_lookup(monkey with pytest.raises(ValueError, match="repo_owner and repo_name must be provided together"): await SandboxManager().create_sandbox( - SandboxConfig(repo_owner="acme", repo_name=None, repo_image_id="repo-image-1") + SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", + repo_owner="acme", + repo_name=None, + repo_image_id="repo-image-1", + ) ) from_id.assert_not_called() @@ -246,12 +259,20 @@ async def test_launch_preserves_image_error_classification( with pytest.raises(expected_error) as raised: if image_source == "snapshot": await SandboxManager().restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="image-1", session_config={"repo_owner": "acme", "repo_name": "repo"}, ) else: await SandboxManager().create_sandbox( - SandboxConfig(repo_owner="acme", repo_name="repo", repo_image_id="image-1") + SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", + repo_owner="acme", + repo_name="repo", + repo_image_id="image-1", + ) ) if expected_error is RepositoryImageUnavailableError: @@ -276,7 +297,14 @@ async def test_base_image_spawn_errors_propagate_without_retry(monkeypatch, miss monkeypatch.setattr("src.sandbox.launch.modal.Sandbox.create", SimpleNamespace(aio=create)) with pytest.raises(type(error)) as raised: - await SandboxManager().create_sandbox(SandboxConfig(repo_owner=None, repo_name=None)) + await SandboxManager().create_sandbox( + SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", + repo_owner=None, + repo_name=None, + ) + ) assert raised.value is error create.assert_awaited_once() @@ -317,6 +345,8 @@ async def test_launch_returns_handle_despite_tunnel_failures(monkeypatch, image_ if image_source == "snapshot": handle = await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="image-1", session_config={"repo_owner": "acme", "repo_name": "repo"}, **common, @@ -324,6 +354,8 @@ async def test_launch_returns_handle_despite_tunnel_failures(monkeypatch, image_ else: handle = await manager.create_sandbox( SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", repo_image_id="image-1" if image_source == "repository" else None, @@ -365,7 +397,13 @@ async def create_aio(*_args, **_kwargs): with pytest.raises(RepositoryImageUnavailableError) as exc_info: await SandboxManager().create_sandbox( - SandboxConfig(repo_owner="acme", repo_name="repo", repo_image_id="image-1") + SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", + repo_owner="acme", + repo_name="repo", + repo_image_id="image-1", + ) ) assert isinstance(exc_info.value.__cause__, NotFoundError) @@ -386,7 +424,13 @@ def missing_secret(_name, **_kwargs): with pytest.raises(NotFoundError, match="secret not found"): await SandboxManager().create_sandbox( - SandboxConfig(repo_owner="acme", repo_name="repo", repo_image_id="repo-image-1") + SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", + repo_owner="acme", + repo_name="repo", + repo_image_id="repo-image-1", + ) ) create.aio.assert_not_awaited() @@ -398,7 +442,14 @@ async def test_base_image_not_found_is_not_classified_as_repository_image(monkey monkeypatch.setattr("src.sandbox.launch.modal.Sandbox.create", create) with pytest.raises(NotFoundError, match="base image not found"): - await SandboxManager().create_sandbox(SandboxConfig(repo_owner="acme", repo_name="repo")) + await SandboxManager().create_sandbox( + SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", + repo_owner="acme", + repo_name="repo", + ) + ) create.aio.assert_awaited_once() @@ -429,6 +480,8 @@ def _docker_config(**overrides) -> SandboxConfig: ), "control_plane_url": "https://control.example", "sandbox_auth_token": "token", + "clone_host": "github.com", + "clone_username": "x-access-token", "user_env_vars": {DOCKER_ENABLED_ENV_VAR: "false", "CUSTOM_ENV": "preserved"}, "settings": dict(DOCKER_SETTINGS), "sandbox_backend": "modal-vm", @@ -464,6 +517,8 @@ async def test_docker_launch_selects_vm_runtime_and_named_allocation( if image_source == "snapshot": handle = await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="snapshot-1", session_config={"session_id": "session-1", "repo_owner": "acme", "repo_name": "repo"}, sandbox_id="sandbox-acme-repo-1700000000000", @@ -569,6 +624,8 @@ async def test_expired_vm_launch_cannot_materialize_after_lookup(monkeypatch, im launch = manager.create_sandbox(_docker_config(launch_deadline_at_ms=1)) else: launch = manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="snapshot-1", session_config={"session_id": "session-1"}, sandbox_id="sandbox-acme-repo-1700000000000", @@ -647,6 +704,8 @@ async def launch(): if image_source == "base": return await manager.create_sandbox(config) return await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="snapshot-1", session_config=config.session_config, sandbox_id=config.sandbox_id, @@ -947,13 +1006,21 @@ async def test_launch_rejects_boolean_tunnel_ports(monkeypatch, restore, ports, if restore: handle = await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="image-1", session_config={"repo_owner": "acme", "repo_name": "repo"}, settings=settings, ) else: handle = await manager.create_sandbox( - SandboxConfig(repo_owner="acme", repo_name="repo", settings=settings) + SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", + repo_owner="acme", + repo_name="repo", + settings=settings, + ) ) kwargs = create.call_args.kwargs diff --git a/packages/modal-infra/tests/test_sandbox_resources.py b/packages/modal-infra/tests/test_sandbox_resources.py index 2505511766..09595dc73e 100644 --- a/packages/modal-infra/tests/test_sandbox_resources.py +++ b/packages/modal-infra/tests/test_sandbox_resources.py @@ -58,6 +58,8 @@ async def test_create_sandbox_passes_cpu_and_memory(self, monkeypatch): manager = SandboxManager() await manager.create_sandbox( SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", settings={"cpuCores": 2, "memoryMib": 4096}, @@ -86,6 +88,8 @@ class FakeImage: manager = SandboxManager() await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-abc", session_config={"repo_owner": "acme", "repo_name": "repo"}, settings={"cpuCores": 1, "memoryMib": 2048}, diff --git a/packages/modal-infra/tests/test_ttyd.py b/packages/modal-infra/tests/test_ttyd.py index 3ddd6ae978..dddf1c5631 100644 --- a/packages/modal-infra/tests/test_ttyd.py +++ b/packages/modal-infra/tests/test_ttyd.py @@ -143,6 +143,8 @@ class FakeSandbox: manager = SandboxManager() config = SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", control_plane_url="https://cp.example.com", @@ -180,6 +182,8 @@ class FakeSandbox: manager = SandboxManager() config = SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", control_plane_url="https://cp.example.com", @@ -231,6 +235,8 @@ class FakeSandbox: manager = SandboxManager() handle = await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-abc", session_config={ "repo_owner": "acme", @@ -278,6 +284,8 @@ class FakeSandbox: manager = SandboxManager() handle = await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-abc", session_config={ "repo_owner": "acme", diff --git a/packages/modal-infra/tests/test_tunnel_ports.py b/packages/modal-infra/tests/test_tunnel_ports.py index ead6c6b51a..96a826e75e 100644 --- a/packages/modal-infra/tests/test_tunnel_ports.py +++ b/packages/modal-infra/tests/test_tunnel_ports.py @@ -414,6 +414,8 @@ class FakeSandbox: manager = SandboxManager() await manager.create_sandbox( SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", settings={"tunnelPorts": [3000, 5173]}, @@ -444,7 +446,14 @@ class FakeSandbox: ) manager = SandboxManager() - await manager.create_sandbox(SandboxConfig(repo_owner="acme", repo_name="repo")) + await manager.create_sandbox( + SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", + repo_owner="acme", + repo_name="repo", + ) + ) assert EXPECTED_TUNNEL_PORTS_ENV_VAR not in captured["env"] @@ -479,6 +488,8 @@ class FakeSandbox: manager = SandboxManager() await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-abc", session_config={"repo_owner": "acme", "repo_name": "repo"}, settings={"tunnelPorts": [3000]}, @@ -606,6 +617,8 @@ async def test_sets_code_server_port_env_when_enabled(self, monkeypatch): manager = SandboxManager() await manager.create_sandbox( SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", code_server_enabled=True, @@ -623,6 +636,8 @@ async def test_omits_code_server_port_env_when_disabled(self, monkeypatch): manager = SandboxManager() await manager.create_sandbox( SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", code_server_enabled=False, @@ -640,6 +655,8 @@ async def test_sets_terminal_port_env_when_terminal_enabled(self, monkeypatch): manager = SandboxManager() await manager.create_sandbox( SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", settings={"terminalEnabled": True, "terminalPort": 7000}, diff --git a/packages/modal-infra/tests/test_vm_resolve.py b/packages/modal-infra/tests/test_vm_resolve.py index 4513793da0..a72d72110d 100644 --- a/packages/modal-infra/tests/test_vm_resolve.py +++ b/packages/modal-infra/tests/test_vm_resolve.py @@ -431,6 +431,8 @@ async def test_vm_launch_reports_typed_outcomes(monkeypatch, endpoint, case, det "sandbox_id": GENERATION, "control_plane_url": "https://control.example", "sandbox_auth_token": "secret", + "clone_host": "github.com", + "clone_username": "x-access-token", "sandbox_backend": "modal-vm", "launch_deadline_at_ms": 1 if case == "expired" else 9999999999999, } diff --git a/packages/modal-infra/tests/test_vnc.py b/packages/modal-infra/tests/test_vnc.py index 18e2412789..d0c899f709 100644 --- a/packages/modal-infra/tests/test_vnc.py +++ b/packages/modal-infra/tests/test_vnc.py @@ -45,6 +45,8 @@ async def test_returns_url_and_password_and_exposes_only_novnc(self, monkeypatch handle = await SandboxManager().create_sandbox( SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", vnc_enabled=True, @@ -71,7 +73,12 @@ async def test_disabled_vnc_has_no_credentials_or_port(self, monkeypatch): ) handle = await SandboxManager().create_sandbox( - SandboxConfig(repo_owner="acme", repo_name="repo") + SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", + repo_owner="acme", + repo_name="repo", + ) ) assert handle.vnc_url is None @@ -96,6 +103,8 @@ async def test_generates_credentials_and_returns_them_with_url(self, monkeypatch ) handle = await SandboxManager().restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-1", session_config={"repo_owner": "acme", "repo_name": "repo"}, vnc_enabled=True, diff --git a/packages/modal-infra/tests/test_web_api_build_sandbox.py b/packages/modal-infra/tests/test_web_api_build_sandbox.py index 362eeb5a3e..e08d33fe52 100644 --- a/packages/modal-infra/tests/test_web_api_build_sandbox.py +++ b/packages/modal-infra/tests/test_web_api_build_sandbox.py @@ -188,7 +188,12 @@ def _patch_dependencies(monkeypatch: pytest.MonkeyPatch): return service +VCS_IDENTITY = {"clone_host": "github.com", "clone_username": "x-access-token"} + + async def _call(endpoint, request: dict) -> dict: + if endpoint is web_api.api_create_build_sandbox: + request = {**VCS_IDENTITY, **request} return await endpoint.get_raw_f()( request, authorization="Bearer test", @@ -277,9 +282,9 @@ async def test_create_build_sandbox_forwards_callback_context_and_returns_provid repositories=REPOSITORIES, callback_url="https://worker.test/image-builds/build-complete", failure_callback_url="https://worker.test/image-builds/build-failed", + clone_host="github.com", + clone_username="x-access-token", clone_token="clone-token", - clone_host=None, - clone_username=None, user_env_vars={"FOO": "bar"}, build_execution_timeout_seconds=DEFAULT_BUILD_TIMEOUT_SECONDS, timeout_seconds=2400, diff --git a/packages/modal-infra/tests/test_web_api_create_sandbox.py b/packages/modal-infra/tests/test_web_api_create_sandbox.py index 52b170f28f..393c5a96cf 100644 --- a/packages/modal-infra/tests/test_web_api_create_sandbox.py +++ b/packages/modal-infra/tests/test_web_api_create_sandbox.py @@ -1,6 +1,7 @@ """Tests for Modal create-sandbox API request assembly.""" import asyncio +import os from types import SimpleNamespace from unittest.mock import ANY, AsyncMock, MagicMock @@ -72,7 +73,10 @@ async def restore_from_snapshot(self, **kwargs): monkeypatch.setattr(manager_module, "SandboxManager", FakeManager) -async def _call_create_sandbox(request: dict, **headers) -> dict: +VCS_IDENTITY = {"clone_host": "github.com", "clone_username": "x-access-token"} + + +async def _call_create_sandbox(request: dict, *, with_identity: bool = True, **headers) -> dict: request_headers = { "authorization": "Bearer test", "x_trace_id": None, @@ -82,12 +86,12 @@ async def _call_create_sandbox(request: dict, **headers) -> dict: **headers, } return await web_api.api_create_sandbox.get_raw_f()( - request, + {**VCS_IDENTITY, **request} if with_identity else request, **request_headers, ) -async def _call_restore_sandbox(request: dict, **headers) -> dict: +async def _call_restore_sandbox(request: dict, *, with_identity: bool = True, **headers) -> dict: request_headers = { "authorization": "Bearer test", "x_trace_id": None, @@ -97,7 +101,7 @@ async def _call_restore_sandbox(request: dict, **headers) -> dict: **headers, } return await web_api.api_restore_sandbox.get_raw_f()( - request, + {**VCS_IDENTITY, **request} if with_identity else request, **request_headers, ) @@ -156,6 +160,63 @@ async def test_sandbox_requests_reject_invalid_typed_fields(monkeypatch, call, p assert exc_info.value.status_code == 400 +@pytest.mark.parametrize("endpoint", [web_api.api_create_sandbox, web_api.api_restore_sandbox]) +def test_sandbox_endpoints_do_not_bind_github_app_secrets(endpoint): + assert [secret.name for secret in endpoint.spec.secrets] == [ + "internal-api", + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("restore", [False, True], ids=["create", "restore"]) +@pytest.mark.parametrize("repo_fields", [{}, {"repo_owner": "acme", "repo_name": "repo"}]) +@pytest.mark.parametrize("field", ["clone_host", "clone_username"]) +@pytest.mark.parametrize("value", [123, True, [], {}]) +async def test_sandbox_requests_reject_invalid_clone_fields( + monkeypatch, restore, repo_fields, field, value +): + _patch_auth(monkeypatch) + if restore: + call = _call_restore_sandbox + request = { + **RESTORE_REQUEST, + "session_config": {**RESTORE_REQUEST["session_config"], **repo_fields}, + } + else: + call = _call_create_sandbox + request = {**CREATE_REQUEST, **repo_fields} + + with pytest.raises(HTTPException) as exc_info: + await call({**request, field: value}) + + assert exc_info.value.status_code == 400 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("restore", [False, True], ids=["create", "restore"]) +@pytest.mark.parametrize( + "identity", + [ + {}, + {"clone_username": "oauth2"}, + {"clone_host": "gitlab.example"}, + {"clone_host": None, "clone_username": None}, + {"clone_host": "", "clone_username": ""}, + ], + ids=["omitted", "host-missing", "username-missing", "null", "empty"], +) +async def test_sandbox_requests_require_vcs_identity(monkeypatch, restore, identity): + """The control plane is the only source of the sandbox VCS identity.""" + _patch_auth(monkeypatch) + call = _call_restore_sandbox if restore else _call_create_sandbox + request = RESTORE_REQUEST if restore else CREATE_REQUEST + + with pytest.raises(HTTPException) as exc_info: + await call({**request, **identity}, with_identity=False) + + assert exc_info.value.status_code == 400 + + @pytest.mark.asyncio @pytest.mark.parametrize( ("call", "payload", "field"), @@ -410,27 +471,25 @@ def reject_auth(_authorization): @pytest.mark.asyncio -async def test_create_sandbox_does_not_resolve_clone_token_for_fresh_boot(monkeypatch): - """Fresh base-image boots authenticate via the credential helper only.""" +@pytest.mark.parametrize("repo_image_id", [None, "repo-image-1"]) +async def test_create_sandbox_passes_broker_context(monkeypatch, repo_image_id): + """Fresh and repo-image boots authenticate via the credential helper only.""" captured = {} - calls = [] - _patch_auth(monkeypatch) _patch_manager(monkeypatch, captured) - monkeypatch.setattr(web_api, "resolve_clone_token", lambda: calls.append(True) or "ghs_token") result = await _call_create_sandbox( { - "session_id": "sess-1", + **CREATE_REQUEST, "repo_owner": "acme", "repo_name": "repo", - "control_plane_url": "https://control-plane.example", - "sandbox_auth_token": "sandbox-token", + "repo_image_id": repo_image_id, } ) assert result["success"] is True - assert calls == [] + assert captured["config"].control_plane_url == CREATE_REQUEST["control_plane_url"] + assert captured["config"].sandbox_auth_token == CREATE_REQUEST["sandbox_auth_token"] @pytest.mark.asyncio @@ -516,36 +575,6 @@ async def test_create_sandbox_rejects_invalid_timeout(monkeypatch, timeout_secon assert exc_info.value.detail == "timeout_seconds must be a positive integer" -@pytest.mark.asyncio -async def test_create_sandbox_does_not_resolve_clone_token_for_repo_image_boot(monkeypatch): - """Repo-image boots authenticate via brokered credentials only.""" - captured = {} - calls = [] - - _patch_auth(monkeypatch) - _patch_manager(monkeypatch, captured) - - def resolve_clone_token() -> str: - calls.append(True) - return "ghs_prebuilt" - - monkeypatch.setattr(web_api, "resolve_clone_token", resolve_clone_token) - - result = await _call_create_sandbox( - { - "session_id": "sess-1", - "repo_owner": "acme", - "repo_name": "repo", - "control_plane_url": "https://control-plane.example", - "sandbox_auth_token": "sandbox-token", - "repo_image_id": "repo-image-1", - } - ) - - assert result["success"] is True - assert calls == [] - - @pytest.mark.asyncio async def test_create_sandbox_threads_missing_repo_fields(monkeypatch): """No-repository sandboxes are represented by null repo fields.""" @@ -598,14 +627,31 @@ async def test_create_sandbox_rejects_partial_repo_context(monkeypatch, request_ @pytest.mark.asyncio -async def test_restore_sandbox_without_repo_does_not_resolve_clone_token(monkeypatch): - """No-repository snapshot restores must not mint a repository clone token.""" +@pytest.mark.parametrize( + "clone_fields", + [ + {}, + {"clone_token": None}, + {"clone_token": ""}, + {"clone_token": "provided-token"}, + {"clone_token": {}}, + ], +) +@pytest.mark.parametrize("repo_fields", [{}, {"repo_owner": "acme", "repo_name": "repo"}]) +async def test_restore_sandbox_never_resolves_or_forwards_static_clone_token( + monkeypatch, clone_fields, repo_fields +): + """Restore ignores obsolete token input and credentials in the function environment.""" captured = {} - calls = [] _patch_auth(monkeypatch) _patch_restore_manager(monkeypatch, captured) - monkeypatch.setattr(web_api, "resolve_clone_token", lambda: calls.append(True) or "ghs_token") + monkeypatch.setenv("GITLAB_ACCESS_TOKEN", "system-gitlab-token") + monkeypatch.setenv("GITHUB_APP_ID", "123") + monkeypatch.setenv("GITHUB_APP_PRIVATE_KEY", "private-key") + monkeypatch.setenv("GITHUB_APP_INSTALLATION_ID", "456") + environment_lookup = MagicMock(wraps=os.environ.get) + monkeypatch.setattr(os.environ, "get", environment_lookup) result = await _call_restore_sandbox( { @@ -614,15 +660,30 @@ async def test_restore_sandbox_without_repo_does_not_resolve_clone_token(monkeyp "session_id": "sess-1", "provider": "anthropic", "model": "claude-sonnet-4-6", + **repo_fields, }, "control_plane_url": "https://control-plane.example", "sandbox_auth_token": "sandbox-token", + **clone_fields, + "clone_host": "gitlab.example", + "clone_username": "oauth2", } ) assert result["success"] is True - assert calls == [] - assert captured["restore"]["clone_token"] is None + assert {call.args[0] for call in environment_lookup.call_args_list}.isdisjoint( + { + "GITLAB_ACCESS_TOKEN", + "GITHUB_APP_ID", + "GITHUB_APP_PRIVATE_KEY", + "GITHUB_APP_INSTALLATION_ID", + } + ) + assert "clone_token" not in captured["restore"] + assert captured["restore"]["clone_host"] == "gitlab.example" + assert captured["restore"]["clone_username"] == "oauth2" + assert captured["restore"]["control_plane_url"] == RESTORE_REQUEST["control_plane_url"] + assert captured["restore"]["sandbox_auth_token"] == RESTORE_REQUEST["sandbox_auth_token"] @pytest.mark.asyncio @@ -674,37 +735,53 @@ async def test_restore_sandbox_forwards_vnc_and_returns_credentials(monkeypatch) @pytest.mark.asyncio -async def test_restore_sandbox_uses_normalized_repo_context(monkeypatch): - """Snapshot restores should validate and pass a single normalized repo context.""" +@pytest.mark.parametrize("restore", [False, True], ids=["create", "restore"]) +@pytest.mark.parametrize("repo_fields", [{}, {"repo_owner": " acme ", "repo_name": " repo "}]) +async def test_sandbox_requests_forward_metadata_and_user_tokens_with_normalized_repo_context( + monkeypatch, restore, repo_fields +): + """Create and restore share launch metadata, outside the nested session config.""" captured = {} - calls = [] _patch_auth(monkeypatch) - _patch_restore_manager(monkeypatch, captured) - monkeypatch.setattr(web_api, "resolve_clone_token", lambda: calls.append(True) or "ghs_token") + if restore: + _patch_restore_manager(monkeypatch, captured) + call = _call_restore_sandbox + request = { + **RESTORE_REQUEST, + "session_config": {**RESTORE_REQUEST["session_config"], **repo_fields}, + } + else: + _patch_manager(monkeypatch, captured) + call = _call_create_sandbox + request = {**CREATE_REQUEST, **repo_fields} - result = await _call_restore_sandbox( + result = await call( { - "snapshot_image_id": "img-abc", - "session_config": { - "session_id": "sess-1", - "repo_owner": " acme ", - "repo_name": " repo ", - "provider": "anthropic", - "model": "claude-sonnet-4-6", - }, - "control_plane_url": "https://control-plane.example", - "sandbox_auth_token": "sandbox-token", + **request, + "clone_host": "gitlab.example", + "clone_username": "oauth2", + "user_env_vars": {"GH_TOKEN": "user-token", "VCS_CLONE_TOKEN": "user-clone-token"}, } ) - session_config = captured["restore"]["session_config"] + config = captured["restore"] if restore else vars(captured["config"]) + session_config = config["session_config"] if restore else config["session_config"].model_dump() assert result["success"] is True - assert calls == [True] - assert session_config["repo_owner"] == "acme" - assert session_config["repo_name"] == "repo" - assert captured["restore"]["clone_token"] == "ghs_token" + assert session_config.get("repo_owner") == ("acme" if repo_fields else None) + assert session_config.get("repo_name") == ("repo" if repo_fields else None) + assert "clone_host" not in session_config + assert "clone_username" not in session_config + assert "clone_token" not in config + assert config["clone_host"] == "gitlab.example" + assert config["clone_username"] == "oauth2" + assert config["control_plane_url"] == request["control_plane_url"] + assert config["sandbox_auth_token"] == request["sandbox_auth_token"] + assert config["user_env_vars"] == { + "GH_TOKEN": "user-token", + "VCS_CLONE_TOKEN": "user-clone-token", + } @pytest.mark.asyncio diff --git a/packages/modal-infra/uv.lock b/packages/modal-infra/uv.lock index 48f4e02546..23b4be2895 100644 --- a/packages/modal-infra/uv.lock +++ b/packages/modal-infra/uv.lock @@ -1044,7 +1044,6 @@ dependencies = [ { name = "modal" }, { name = "open-inspect-sandbox-runtime" }, { name = "pydantic" }, - { name = "pyjwt", extra = ["crypto"] }, ] [package.optional-dependencies] @@ -1063,7 +1062,6 @@ requires-dist = [ { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.14.0" }, { name = "open-inspect-sandbox-runtime", editable = "../sandbox-runtime" }, { name = "pydantic", specifier = ">=2.0" }, - { name = "pyjwt", extras = ["crypto"], specifier = ">=2.14.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9.0" }, @@ -1079,7 +1077,6 @@ dependencies = [ { name = "cryptography" }, { name = "httpx" }, { name = "pydantic" }, - { name = "pyjwt", extra = ["crypto"] }, { name = "websockets" }, ] @@ -1090,7 +1087,6 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.14.0" }, { name = "pydantic", specifier = ">=2.0" }, - { name = "pyjwt", extras = ["crypto"], specifier = ">=2.14.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9.0" }, diff --git a/packages/sandbox-runtime/pyproject.toml b/packages/sandbox-runtime/pyproject.toml index c7936ac5bf..8568a9cb86 100644 --- a/packages/sandbox-runtime/pyproject.toml +++ b/packages/sandbox-runtime/pyproject.toml @@ -8,7 +8,6 @@ dependencies = [ "httpx>=0.27.0", "websockets>=13.0", "pydantic>=2.0", - "PyJWT[crypto]>=2.14.0", # Exact pin: the wheel bundles the `claude` binary and its message shapes # are what harness/claude.py translates. "claude-agent-sdk==0.2.161", diff --git a/packages/sandbox-runtime/src/sandbox_runtime/auth/__init__.py b/packages/sandbox-runtime/src/sandbox_runtime/auth/__init__.py index 6f6e25a980..0488bdf804 100644 --- a/packages/sandbox-runtime/src/sandbox_runtime/auth/__init__.py +++ b/packages/sandbox-runtime/src/sandbox_runtime/auth/__init__.py @@ -1,6 +1,5 @@ """Authentication utilities for Open-Inspect sandbox runtime.""" -from .github_app import generate_installation_token from .internal import ( AuthConfigurationError, require_secret, @@ -9,7 +8,6 @@ __all__ = [ "AuthConfigurationError", - "generate_installation_token", "require_secret", "verify_internal_token", ] diff --git a/packages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py b/packages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py deleted file mode 100644 index 43a8f9b485..0000000000 --- a/packages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py +++ /dev/null @@ -1,90 +0,0 @@ -""" -GitHub App token generation for git operations. - -Generates short-lived installation access tokens for: -- Cloning private repositories during image builds -- Git fetch/sync at sandbox startup -- Git push when creating pull requests - -Tokens are valid for ~1 hour. -""" - -import time - -import httpx -import jwt - - -def generate_jwt(app_id: str, private_key: str) -> str: - """ - Generate a JWT for GitHub App authentication. - - Args: - app_id: The GitHub App's ID - private_key: The App's private key (PEM format) - - Returns: - Signed JWT valid for 10 minutes - """ - now = int(time.time()) - payload = { - "iat": now - 60, # Issued 60 seconds ago (clock skew tolerance) - "exp": now + 600, # Expires in 10 minutes - "iss": app_id, - } - return jwt.encode(payload, private_key, algorithm="RS256") - - -def get_installation_token(jwt_token: str, installation_id: str) -> str: - """ - Exchange a JWT for an installation access token. - - Args: - jwt_token: The signed JWT - installation_id: The GitHub App installation ID - - Returns: - Installation access token (valid for 1 hour) - - Raises: - httpx.HTTPStatusError: If the GitHub API request fails - """ - url = f"https://api.github.com/app/installations/{installation_id}/access_tokens" - headers = { - "Authorization": f"Bearer {jwt_token}", - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - } - - with httpx.Client() as client: - response = client.post(url, headers=headers) - response.raise_for_status() - return response.json()["token"] - - -def generate_installation_token( - app_id: str, - private_key: str, - installation_id: str, -) -> str: - """ - Generate a fresh GitHub App installation token. - - This is the main entry point for token generation. It: - 1. Creates a JWT signed with the App's private key - 2. Exchanges it for an installation access token - - Args: - app_id: The GitHub App's ID - private_key: The App's private key (PEM format) - installation_id: The GitHub App installation ID - - Returns: - Installation access token (valid for 1 hour) - - Raises: - httpx.HTTPStatusError: If the GitHub API request fails - jwt.PyJWTError: If JWT encoding fails - """ - jwt_token = generate_jwt(app_id, private_key) - return get_installation_token(jwt_token, installation_id) diff --git a/packages/sandbox-runtime/src/sandbox_runtime/bridge.py b/packages/sandbox-runtime/src/sandbox_runtime/bridge.py index 4e5cfa7162..86035e2acf 100644 --- a/packages/sandbox-runtime/src/sandbox_runtime/bridge.py +++ b/packages/sandbox-runtime/src/sandbox_runtime/bridge.py @@ -50,6 +50,7 @@ ) from .diff_capture import ControlPlaneDiffClient, SessionDiffRefreshWorker from .event_forwarder import BufferedEventForwarder +from .event_size import MAX_EVENT_BYTES, event_size_bytes from .git_signing import GitSigningError, GitSigningRuntime from .harness import ( DEFAULT_HARNESS_ID, @@ -690,9 +691,10 @@ async def _handle_prompt(self, cmd: dict[str, Any]) -> dict[str, Any]: ) emitted_output = False + text_undelivered = False async def emit(event: dict[str, Any]) -> None: - nonlocal emitted_output, message_cost_usd + nonlocal emitted_output, text_undelivered, message_cost_usd if event.get("type") == "execution_complete": raise RuntimeError("harness must not emit execution_complete") if event.get("type") in ("token", "tool_call", "step_finish"): @@ -702,7 +704,17 @@ async def emit(event: dict[str, Any]) -> None: # When an outcome does arrive it is authoritative (below). if event.get("type") == "step_finish" and "messageCostUsd" in event: message_cost_usd = event["messageCostUsd"] - await self._send_event(event) + delivered = await self._send_event(event) + # Token content is cumulative, so a token the forwarder refuses + # as oversized (it sizes the event after stamping it in place) + # leaves the control plane's copy of that text incomplete. Any + # other undelivered token was buffered for replay. + if ( + not delivered + and event.get("type") == "token" + and event_size_bytes(event) > MAX_EVENT_BYTES + ): + text_undelivered = True turn: TurnOutcome = await harness.run_prompt( HarnessPrompt( @@ -720,7 +732,7 @@ async def emit(event: dict[str, Any]) -> None: ) await self._persist_rotated_session_id(harness) # The outcome is authoritative for cost and success once it - # exists; the bridge adds only the no-output guard below. + # exists; the bridge adds only the output guards below. if turn.message_cost_usd is not None: message_cost_usd = turn.message_cost_usd if not turn.success: @@ -739,6 +751,19 @@ async def emit(event: dict[str, Any]) -> None: reasoning_effort=reasoning_effort, ) + if not had_error and text_undelivered: + had_error = True + error_message = ( + "The agent's response exceeded the event size limit and was not " + "delivered in full." + ) + self.log.error( + "prompt.text_undelivered", + message_id=message_id, + model=model, + reasoning_effort=reasoning_effort, + ) + if had_error: outcome = "error" diff --git a/packages/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py b/packages/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py index 491ef7a38f..98d5235cdc 100644 --- a/packages/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py +++ b/packages/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py @@ -268,27 +268,10 @@ def _emit_response(input_lines: dict[str, str], credentials: dict[str, object]) def _gh_wrapper_should_mint(env: Mapping[str, str]) -> bool: - """Decide whether the gh CLI needs a freshly-minted token. - - gh reads ``GH_TOKEN`` then ``GITHUB_TOKEN`` from its own environment, so - we mint only when the environment has nothing usable: no user-provided - token, and either nothing at all or just the system's short-lived - installation fallback (marked ``OI_GITHUB_TOKEN_IS_FALLBACK=1``, which - expires in ~1h and must be refreshed). A user-provided token always wins. - - The marker is authoritative on its own: a value comparison between - ``GITHUB_TOKEN`` and ``GITHUB_APP_TOKEN`` is not needed to detect a user - override, because the manager only sets the marker when it injected both - values itself. - """ + """Mint for GitHub only when neither CLI-supported user token is present.""" if env.get("VCS_HOST", "github.com").strip().lower() != "github.com": return False # non-github deployment: never touch gh's own auth - if env.get("GH_TOKEN"): - return False # user-owned; the manager never injects GH_TOKEN - if env.get("OI_GITHUB_TOKEN_IS_FALLBACK") == "1": - return True # only the expiring system fallback is present → refresh - # Otherwise mint only when there's no genuine user token to leave alone. - return not (env.get("GITHUB_TOKEN") or env.get("GITHUB_APP_TOKEN")) + return not (env.get("GH_TOKEN") or env.get("GITHUB_TOKEN")) def _print_gh_token() -> int: diff --git a/packages/sandbox-runtime/tests/conftest.py b/packages/sandbox-runtime/tests/conftest.py index adfe37504e..18ac21f59f 100644 --- a/packages/sandbox-runtime/tests/conftest.py +++ b/packages/sandbox-runtime/tests/conftest.py @@ -23,7 +23,6 @@ "GH_TOKEN", "GITHUB_TOKEN", "GITHUB_APP_TOKEN", - "OI_GITHUB_TOKEN_IS_FALLBACK", ) diff --git a/packages/sandbox-runtime/tests/test_bridge_event_buffer.py b/packages/sandbox-runtime/tests/test_bridge_event_buffer.py index 30c6926720..25e891badc 100644 --- a/packages/sandbox-runtime/tests/test_bridge_event_buffer.py +++ b/packages/sandbox-runtime/tests/test_bridge_event_buffer.py @@ -5,7 +5,8 @@ Forwarder-level buffering/flush/eviction mechanics are covered in test_event_forwarder.py; here we test only what the bridge owns: binding and unbinding the forwarder around a connection, delivery of events produced -while disconnected, and prompt tasks surviving WS disconnects. +while disconnected, prompt tasks surviving WS disconnects, and the turn +outcome when assistant text cannot be delivered. """ import asyncio @@ -17,7 +18,8 @@ from websockets import State from sandbox_runtime.bridge import AgentBridge -from tests.conftest import MockResponse, wire_opencode_transport +from sandbox_runtime.event_size import MAX_EVENT_BYTES, event_size_bytes +from tests.conftest import MockResponse, ScriptedHarness, wire_opencode_transport class MockHttpClient: @@ -206,5 +208,94 @@ async def test_execution_complete_while_disconnected_delivered_on_bind( assert parsed["success"] is True +class TestAssistantTextDelivery: + """A turn succeeds only if its assistant text can reach the control plane.""" + + UNDELIVERED = ( + "The agent's response exceeded the event size limit and was not delivered in full." + ) + + @staticmethod + async def _run( + bridge: AgentBridge, *events: dict[str, Any], ws: FakeWs | None = None + ) -> dict[str, Any]: + """Run one scripted turn, with the forwarder bound to ``ws`` if given.""" + + async def stream(message_id: str, _text: str): + for event in events: + yield {**event, "messageId": message_id} + + bridge.boot_attach.harness = ScriptedHarness(stream) + bridge._configure_git_identity = AsyncMock() + if ws is not None: + await bridge.event_forwarder.bind(ws) + return await bridge._handle_prompt( + { + "messageId": "msg-1", + "content": "summarize the repository", + "author": {"gitIdentity": {"mode": "agent-only"}}, + } + ) + + @pytest.mark.asyncio + async def test_text_past_the_event_limit_fails_the_turn(self, bridge: AgentBridge): + # Token content is cumulative: every event carries the whole text so + # far, so once it passes the limit the rest of the answer only ever + # travels in events the forwarder refuses to send. + opening = "Reading the repository." + answer = f"{opening}\n\n{'x' * MAX_EVENT_BYTES}\n\nFinal answer." + ws = FakeWs() + + completion = await self._run( + bridge, + {"type": "token", "content": opening}, + {"type": "token", "content": answer}, + ws=ws, + ) + + sent = [json.loads(data) for data in ws.sent] + assert [event["content"] for event in sent if event["type"] == "token"] == [opening] + assert completion["success"] is False + assert completion["error"] == self.UNDELIVERED + + @pytest.mark.asyncio + async def test_text_the_envelope_pushes_past_the_limit_fails_the_turn( + self, bridge: AgentBridge + ): + token = {"type": "token", "messageId": "msg-1", "content": "", "timestamp": 1.0} + # Exactly at the limit until the forwarder stamps sandboxId on it. + token["content"] = "x" * (MAX_EVENT_BYTES - event_size_bytes(token)) + ws = FakeWs() + + completion = await self._run(bridge, token, ws=ws) + + assert ws.sent == [] + assert completion["success"] is False + assert completion["error"] == self.UNDELIVERED + + @pytest.mark.asyncio + async def test_harness_error_outranks_undelivered_text(self, bridge: AgentBridge): + completion = await self._run( + bridge, + {"type": "token", "content": "x" * MAX_EVENT_BYTES}, + {"type": "error", "error": "rate limited"}, + ws=FakeWs(), + ) + + assert completion["success"] is False + assert completion["error"] == "rate limited" + + @pytest.mark.asyncio + async def test_text_buffered_while_disconnected_does_not_fail_the_turn( + self, bridge: AgentBridge + ): + completion = await self._run(bridge, {"type": "token", "content": "Done."}) + + assert completion["success"] is True + ws = FakeWs() + await bridge.event_forwarder.bind(ws) + assert [json.loads(data)["content"] for data in ws.sent] == ["Done."] + + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/packages/sandbox-runtime/tests/test_git_credential_helper.py b/packages/sandbox-runtime/tests/test_git_credential_helper.py index f745616d56..593f649c31 100644 --- a/packages/sandbox-runtime/tests/test_git_credential_helper.py +++ b/packages/sandbox-runtime/tests/test_git_credential_helper.py @@ -553,51 +553,11 @@ def test_control_plane_response_invalid_expiry_is_fatal(cache_dir: Path, env_set ({"VCS_HOST": "github.com"}, True), # Non-github deployment → never touch gh's own auth. ({"VCS_HOST": "gitlab.com"}, False), - # A user-set GH_TOKEN always wins (the manager never injects GH_TOKEN). + # CLI-supported user tokens always win. ({"VCS_HOST": "github.com", "GH_TOKEN": "user"}, False), - # A user token without the fallback marker → leave it in place. ({"VCS_HOST": "github.com", "GITHUB_TOKEN": "user"}, False), - ({"VCS_HOST": "github.com", "GITHUB_APP_TOKEN": "user"}, False), - # Marked system fallback → refresh the soon-to-expire installation token. - ( - { - "VCS_HOST": "github.com", - "GITHUB_TOKEN": "stale", - "GITHUB_APP_TOKEN": "stale", - "OI_GITHUB_TOKEN_IS_FALLBACK": "1", - }, - True, - ), - # Marker with only GITHUB_TOKEN present → still refresh. - ( - { - "VCS_HOST": "github.com", - "GITHUB_TOKEN": "stale", - "OI_GITHUB_TOKEN_IS_FALLBACK": "1", - }, - True, - ), - # Dropped heuristic: the marker alone forces a refresh even when the - # two values differ (this case used to be read as a user override). - ( - { - "VCS_HOST": "github.com", - "GITHUB_TOKEN": "stale", - "GITHUB_APP_TOKEN": "user_app", - "OI_GITHUB_TOKEN_IS_FALLBACK": "1", - }, - True, - ), - # A user GH_TOKEN still wins even with the fallback marker present. - ( - { - "VCS_HOST": "github.com", - "GH_TOKEN": "user", - "GITHUB_TOKEN": "stale", - "OI_GITHUB_TOKEN_IS_FALLBACK": "1", - }, - False, - ), + # gh does not read GITHUB_APP_TOKEN; the wrapper must still obtain a usable token. + ({"VCS_HOST": "github.com", "GITHUB_APP_TOKEN": "user"}, True), ], ) def test_gh_wrapper_should_mint(env: dict[str, str], expected: bool) -> None: diff --git a/packages/sandbox-runtime/uv.lock b/packages/sandbox-runtime/uv.lock index a9f168026e..97357c0abe 100644 --- a/packages/sandbox-runtime/uv.lock +++ b/packages/sandbox-runtime/uv.lock @@ -552,7 +552,6 @@ dependencies = [ { name = "cryptography" }, { name = "httpx" }, { name = "pydantic" }, - { name = "pyjwt", extra = ["crypto"] }, { name = "websockets" }, ] @@ -571,7 +570,6 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.14.0" }, { name = "pydantic", specifier = ">=2.0" }, - { name = "pyjwt", extras = ["crypto"], specifier = ">=2.14.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9.0" }, diff --git a/packages/shared/src/rbac.ts b/packages/shared/src/rbac.ts index 24a60010a7..8bbd4ec47e 100644 --- a/packages/shared/src/rbac.ts +++ b/packages/shared/src/rbac.ts @@ -28,6 +28,17 @@ export const BUILT_IN_ROLE_KEYS = Object.keys(BUILT_IN_ROLE_REGISTRY) as BuiltIn /** Stable IDs reserved for system-defined roles. */ export const BUILT_IN_ROLE_IDS = Object.values(BUILT_IN_ROLE_REGISTRY).map((role) => role.id); +/** Built-in roles that administer the whole workspace, including every team. */ +export const WORKSPACE_ADMIN_ROLE_KEYS = [ + "owner", + "administrator", +] as const satisfies readonly BuiltInRoleKey[]; + +/** Whether a role key administers the workspace (the Owner or an Administrator). */ +export function isWorkspaceAdmin(roleKey: string | null | undefined): boolean { + return (WORKSPACE_ADMIN_ROLE_KEYS as readonly (string | null | undefined)[]).includes(roleKey); +} + /** Canonical permission identifiers accepted by the RBAC policy and persistence layers. */ export const PERMISSION_IDS = [ "analytics.read", diff --git a/packages/shared/src/types/audit-events.test.ts b/packages/shared/src/types/audit-events.test.ts index 7b27fb5f82..86a1b0a907 100644 --- a/packages/shared/src/types/audit-events.test.ts +++ b/packages/shared/src/types/audit-events.test.ts @@ -135,6 +135,7 @@ describe("interpretAuditEvent", () => { "team.member_joined", "team.secret_set", "team.secret_deleted", + "automation.executor_changed", ])("recognizes %s as a domain operation", (action) => { expect(interpretAuditEvent({ action, operationResult: "applied", metadata: {} })).toEqual({ kind: "operation", diff --git a/packages/shared/src/types/audit-events.ts b/packages/shared/src/types/audit-events.ts index 87a152ad33..c5a95f492b 100644 --- a/packages/shared/src/types/audit-events.ts +++ b/packages/shared/src/types/audit-events.ts @@ -90,8 +90,11 @@ export const AUDIT_OPERATION_ACTIONS = [ "team.member_role_changed", "team.member_removed", "team.member_joined", + "team.grant_added", + "team.grant_removed", "team.secret_set", "team.secret_deleted", + "automation.executor_changed", ] as const; export const AUTHORIZATION_DECISION_METADATA_SCHEMA = "authorization_decision.v1"; diff --git a/packages/shared/src/types/automations.test.ts b/packages/shared/src/types/automations.test.ts index f39b0d3654..b81a010905 100644 --- a/packages/shared/src/types/automations.test.ts +++ b/packages/shared/src/types/automations.test.ts @@ -27,6 +27,8 @@ const automation = { consecutiveFailures: 0, createdBy: "user-1", userId: USER_ID, + ownerTeamId: null, + capabilities: { canRead: true, canManage: true, canTrigger: true }, createdAt: 1, updatedAt: 2, deletedAt: null, @@ -39,6 +41,16 @@ const automation = { }; describe("listAutomationsResponseSchema", () => { + it("retains team ownership and server capabilities", () => { + const capabilities = { canRead: true, canManage: false, canTrigger: true }; + const result = listAutomationsResponseSchema.parse({ + automations: [{ ...automation, ownerTeamId: "team_a", capabilities }], + hasMore: false, + nextCursor: null, + }); + expect(result.automations[0]).toMatchObject({ ownerTeamId: "team_a", capabilities }); + }); + it("accepts a valid cursor page", () => { expect( listAutomationsResponseSchema.parse({ @@ -74,6 +86,20 @@ describe("listAutomationsResponseSchema", () => { ).toBe(false); }); + it("requires viewer capabilities and explicit team ownership on every item", () => { + const { capabilities: _capabilities, ...withoutCapabilities } = automation; + const { ownerTeamId: _ownerTeamId, ...withoutOwner } = automation; + for (const item of [withoutCapabilities, withoutOwner]) { + expect( + listAutomationsResponseSchema.safeParse({ + automations: [item], + hasMore: false, + nextCursor: null, + }).success + ).toBe(false); + } + }); + it("requires a canonical owner ID when ownership is present", () => { const response = (userId: string | null) => ({ automations: [{ ...automation, userId }], @@ -166,6 +192,18 @@ describe("automation concurrency bound", () => { }); }); +describe("automation team input", () => { + it.each(["team_a", null])("accepts teamId=%s on create", (teamId) => { + expect( + createAutomationRequestSchema.parse({ + name: "Daily sync", + instructions: "Sync", + teamId, + }) + ).toHaveProperty("teamId", teamId); + }); +}); + describe("automation provider selection contracts", () => { it("accepts complete create selections and returns selections in responses", () => { expect( diff --git a/packages/shared/src/types/automations.ts b/packages/shared/src/types/automations.ts index 3e1599668b..f641745358 100644 --- a/packages/shared/src/types/automations.ts +++ b/packages/shared/src/types/automations.ts @@ -156,6 +156,7 @@ const automationSchema = z.object({ consecutiveFailures: z.number(), createdBy: z.string(), userId: z.string().refine(isCanonicalUserId, "Invalid canonical user ID").nullable(), + ownerTeamId: z.string().nullable(), createdAt: z.number(), updatedAt: z.number(), deletedAt: z.number().nullable(), @@ -168,6 +169,22 @@ const automationSchema = z.object({ export type Automation = z.infer; +const automationCapabilitiesSchema = z.object({ + canRead: z.boolean(), + canManage: z.boolean(), + canTrigger: z.boolean(), +}); + +/** What one viewer may do with an automation. */ +export type AutomationCapabilities = z.infer; + +/** An automation as returned to a viewer, with that viewer's capabilities. */ +const automationViewSchema = automationSchema.extend({ + capabilities: automationCapabilitiesSchema, +}); + +export type AutomationView = z.infer; + const automationExecutionSummarySchema = z.object({ id: z.string(), status: automationInvocationStatusSchema, @@ -176,7 +193,7 @@ const automationExecutionSummarySchema = z.object({ export type AutomationExecutionSummary = z.infer; -const automationListItemSchema = automationSchema.extend({ +const automationListItemSchema = automationViewSchema.extend({ recentExecutions: z.array(automationExecutionSummarySchema), }); @@ -208,6 +225,11 @@ export const sentryClientSecretSchema = z.string().refine((secret) => secret.tri }); export const createAutomationRequestSchema = z.object({ + teamId: z + .string() + .regex(/^team_[A-Za-z0-9_-]+$/) + .nullable() + .optional(), name: z.string(), instructions: z.string(), triggerType: automationTriggerTypeSchema.optional(), diff --git a/packages/shared/src/types/environments.test.ts b/packages/shared/src/types/environments.test.ts index d1d5bb949e..b573d671a8 100644 --- a/packages/shared/src/types/environments.test.ts +++ b/packages/shared/src/types/environments.test.ts @@ -23,6 +23,15 @@ describe("isEnvironmentId", () => { }); describe("createEnvironmentInputSchema", () => { + it.each(["team_a", null])("retains owning team %s", (teamId) => { + expect( + createEnvironmentInputSchema.parse({ + name: "Staging", + teamId, + repositories: [{ repoOwner: "acme", repoName: "web" }], + }) + ).toHaveProperty("teamId", teamId); + }); it("parses a valid environment and normalizes member identifiers", () => { const parsed = createEnvironmentInputSchema.parse({ name: "Full Stack", @@ -139,11 +148,15 @@ describe("updateEnvironmentInputSchema", () => { }); describe("listEnvironmentsResponseSchema", () => { - it("parses a valid environments response with nullable fields", () => { + it.each([ + {}, + { ownerTeamId: "team_a", capabilities: { canRead: true, canManage: false, canUse: true } }, + ])("parses nullable fields and retains optional ownership %j", (ownership) => { const result = listEnvironmentsResponseSchema.safeParse({ environments: [ { id: "env_abc", + ...ownership, name: "Production", description: null, prebuildEnabled: true, @@ -163,6 +176,7 @@ describe("listEnvironmentsResponseSchema", () => { }); expect(result.success).toBe(true); + if (result.success) expect(result.data.environments[0]).toMatchObject(ownership); }); it("rejects malformed environment entries", () => { diff --git a/packages/shared/src/types/environments.ts b/packages/shared/src/types/environments.ts index 9113eea897..e31114acc5 100644 --- a/packages/shared/src/types/environments.ts +++ b/packages/shared/src/types/environments.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { sessionRepositoriesInputSchema } from "./repositories"; +import { teamIdSchema } from "./team-id"; /** Maximum characters in an environment's display name. */ export const MAX_ENVIRONMENT_NAME_LENGTH = 200; @@ -39,6 +40,8 @@ const environmentChannelAssociationsSchema = z .max(MAX_ENVIRONMENT_CHANNEL_ASSOCIATIONS); export const createEnvironmentInputSchema = z.object({ + /** Owner team; null or absent means the workspace owns the environment. */ + teamId: teamIdSchema.nullable().optional(), name: z.string().trim().min(1).max(MAX_ENVIRONMENT_NAME_LENGTH), description: z.string().trim().max(MAX_ENVIRONMENT_DESCRIPTION_LENGTH).nullish(), prebuildEnabled: z.boolean().optional(), @@ -74,6 +77,14 @@ export type EnvironmentRepository = z.infer; /** An environment: a named, prebuildable repository set (design §7.1). */ export const environmentSchema = z.object({ id: z.string(), + ownerTeamId: z.string().nullable().optional(), + capabilities: z + .object({ + canRead: z.boolean(), + canManage: z.boolean(), + canUse: z.boolean(), + }) + .optional(), name: z.string(), description: z.string().nullable(), prebuildEnabled: z.boolean(), diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 75504250b9..fb09614434 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -104,6 +104,7 @@ export { teamMembershipSchema, } from "./teams"; export type { Team, TeamRole, TeamJoinPolicy, SessionVisibility, TeamMembership } from "./teams"; +export { teamIdSchema } from "./team-id"; export { SESSION_ACTIONS, @@ -112,6 +113,7 @@ export { checkSessionAccess, sessionCapabilities, checkAutomationAccess, + checkAutomationExecutorReassignment, automationCapabilities, checkEnvironmentAccess, environmentCapabilities, @@ -302,6 +304,8 @@ export type { AutomationRepository, AutomationRepositoryInput, Automation, + AutomationCapabilities, + AutomationView, AutomationExecutionSummary, AutomationListItem, CreateAutomationRequest, diff --git a/packages/shared/src/types/repository-catalog.ts b/packages/shared/src/types/repository-catalog.ts index d6d542720a..42482a8eef 100644 --- a/packages/shared/src/types/repository-catalog.ts +++ b/packages/shared/src/types/repository-catalog.ts @@ -62,6 +62,7 @@ export const controlPlaneReposResponseSchema = z.object({ repos: z.array(enrichedRepositorySchema), cached: z.boolean(), cachedAt: z.string(), + teamHasRepositoryGrants: z.boolean().optional(), }); export type ControlPlaneReposResponse = z.infer; diff --git a/packages/shared/src/types/session-access.test.ts b/packages/shared/src/types/session-access.test.ts index 2b4ee1f336..225af01e72 100644 --- a/packages/shared/src/types/session-access.test.ts +++ b/packages/shared/src/types/session-access.test.ts @@ -6,6 +6,7 @@ import { SESSION_ACTIONS, automationCapabilities, checkAutomationAccess, + checkAutomationExecutorReassignment, checkEnvironmentAccess, checkSessionAccess, environmentCapabilities, @@ -400,6 +401,25 @@ describe("checkAutomationAccess", () => { }); }); + it("lets only team leads and workspace admins reassign an executor", () => { + const executor = viewer("owner", "member", false, ["automations.manage.own"]); + const member = viewer("team member", "member", false, ["automations.manage.any"]); + const lead = viewer("team lead", "member", false, ["automations.manage.own"]); + const admin = viewer("non-member", "administrator", false, ["automations.manage.any"]); + const denied = { allowed: false, reason: "not_owner_or_lead" }; + expect(checkAutomationExecutorReassignment(executor, target)).toEqual(denied); + expect(checkAutomationExecutorReassignment(member, target)).toEqual(denied); + expect(checkAutomationExecutorReassignment(lead, target)).toEqual({ allowed: true }); + expect(checkAutomationExecutorReassignment(admin, target)).toEqual({ allowed: true }); + expect( + checkAutomationExecutorReassignment(lead, { ownerTeamId: null, executorUserId: "user_owner" }) + ).toEqual(denied); + expect(checkAutomationExecutorReassignment({ kind: "service", teamId: null }, target)).toEqual({ + allowed: false, + reason: "missing_permission", + }); + }); + it.each([ [null, null, true], [null, "team_one", true], @@ -444,7 +464,7 @@ describe("checkEnvironmentAccess", () => { }); }); - it("requires the manage grant and lead/admin role independently", () => { + it("requires the manage grant and lead/admin role independently on team-owned rows", () => { expect( checkEnvironmentAccess( viewer("team lead", null, false, ["environments.use"]), @@ -464,15 +484,61 @@ describe("checkEnvironmentAccess", () => { allowed: true, } ); - expect( - checkEnvironmentAccess( - viewer("team lead", "member", false, ["environments.manage"]), - { ownerTeamId: null }, - "manage" - ) - ).toEqual({ allowed: false, reason: "not_owner_or_lead" }); }); + it.each([ + [null, [], false, false, false, false], + [ + null, + ["environments.secrets.manage", "environments.settings.manage", "environments.images.manage"], + false, + false, + false, + false, + ], + [null, ["environments.read"], false, true, false, false], + [null, ["environments.manage"], false, false, true, false], + ["member", ["environments.manage"], false, false, true, false], + [null, ["environments.use"], false, false, false, true], + [ + null, + ["environments.read", "environments.manage", "environments.use"], + false, + true, + true, + true, + ], + [ + null, + ["environments.read", "environments.manage", "environments.use"], + true, + false, + false, + false, + ], + ] as const)( + "projects workspace grants for role %s, permissions %j, suspended %s", + (role, permissions, suspended, read, manage, use) => { + const workspace = { ownerTeamId: null }; + const actor = viewer("non-member", role, suspended, permissions); + const permits = { read, manage, use }; + for (const action of ENVIRONMENT_ACTIONS) { + expect(checkEnvironmentAccess(actor, workspace, action)).toEqual( + suspended + ? { allowed: false, reason: "suspended" } + : permits[action] + ? { allowed: true } + : { allowed: false, reason: "missing_permission" } + ); + } + expect(environmentCapabilities(actor, workspace)).toEqual({ + canRead: read, + canManage: manage, + canUse: use, + }); + } + ); + it("denies suspended and outside-team users for every action", () => { for (const action of ENVIRONMENT_ACTIONS) { expect(checkEnvironmentAccess(viewer("team lead", "owner", true), target, action)).toEqual({ @@ -491,7 +557,8 @@ describe("checkEnvironmentAccess", () => { [null, "team_other", true], ["team_one", "team_one", true], ["team_one", "team_other", false], - ["team_one", null, true], + // Unbound services launch workspace sessions, which cannot use team environments. + ["team_one", null, false], ] as const)( "limits service environment use for row team %s and binding %s", (ownerTeamId, teamId, allowed) => { diff --git a/packages/shared/src/types/session-access.ts b/packages/shared/src/types/session-access.ts index 9ba7e124a1..ef678e2353 100644 --- a/packages/shared/src/types/session-access.ts +++ b/packages/shared/src/types/session-access.ts @@ -1,4 +1,5 @@ import { + isWorkspaceAdmin, resolveScopedPermission, type BuiltInRoleKey, type PermissionId, @@ -130,7 +131,7 @@ function sessionFacts(viewer: UserViewer, row: SessionAccessRow): SessionFacts { row.collaboratorIds.includes(viewer.userId) && (row.ownerTeamId === null || teamRole !== undefined); const isWsOwner = viewer.roleKey === "owner"; - const isAdmin = isWsOwner || viewer.roleKey === "administrator"; + const isAdmin = isWorkspaceAdmin(viewer.roleKey); const isPrivate = row.visibility === "private"; return { permissions: viewer.permissions, @@ -250,7 +251,7 @@ function automationFacts( row: { ownerTeamId: string | null; executorUserId: string | null } ): AutomationFacts { const teamRole = row.ownerTeamId === null ? undefined : viewer.memberships.get(row.ownerTeamId); - const isAdmin = viewer.roleKey === "owner" || viewer.roleKey === "administrator"; + const isAdmin = isWorkspaceAdmin(viewer.roleKey); return { permissions: viewer.permissions, has: (permission) => viewer.permissions.includes(permission), @@ -297,6 +298,29 @@ export function checkAutomationAccess( return ownedGate(facts) ?? decide(AUTOMATION_RULES[action], facts); } +/** + * Whether the viewer may choose a different executor. Executors cannot hand off their own + * automations; reassignment is a team-lead or workspace-admin decision. Route admission + * separately requires `manage`. + */ +export function checkAutomationExecutorReassignment( + viewer: SessionViewer, + row: { ownerTeamId: string | null; executorUserId: string | null } +): AccessDecision { + if (viewer.kind === "service") return deny("missing_permission"); + const facts = automationFacts(viewer, row); + return ( + ownedGate(facts) ?? + decide( + { + when: (f: AutomationFacts) => f.isAdmin || f.teamRole === "lead", + reason: "not_owner_or_lead", + }, + facts + ) + ); +} + export function automationCapabilities( viewer: SessionViewer, row: { ownerTeamId: string | null; executorUserId: string | null } @@ -308,38 +332,49 @@ export function automationCapabilities( }; } -function environmentFacts(viewer: UserViewer, row: { ownerTeamId: string | null }): OwnedFacts { +interface EnvironmentFacts extends OwnedFacts { + teamOwned: boolean; +} + +function environmentFacts( + viewer: UserViewer, + row: { ownerTeamId: string | null } +): EnvironmentFacts { const teamRole = row.ownerTeamId === null ? undefined : viewer.memberships.get(row.ownerTeamId); - const isAdmin = viewer.roleKey === "owner" || viewer.roleKey === "administrator"; + const isAdmin = isWorkspaceAdmin(viewer.roleKey); return { permissions: viewer.permissions, has: (permission) => viewer.permissions.includes(permission), suspended: viewer.suspended, eligible: row.ownerTeamId === null || teamRole !== undefined || isAdmin, + teamOwned: row.ownerTeamId !== null, teamRole, isAdmin, }; } +// Workspace environments need only the grant; team environments also need a lead or admin. const manageEnvironment = { permission: "environments.manage", - when: (facts: OwnedFacts) => facts.teamRole === "lead" || facts.isAdmin, + when: (facts: EnvironmentFacts) => !facts.teamOwned || facts.teamRole === "lead" || facts.isAdmin, reason: "not_owner_or_lead", } as const; const ENVIRONMENT_RULES = { read: { permission: "environments.read" }, use: { permission: "environments.use" }, manage: manageEnvironment, -} as const satisfies Record>; +} as const satisfies Record>; +/** + * Services launch sessions for their bound team, or for the workspace when unbound, so they + * see only environments those sessions could use. + */ function checkServiceEnvironmentAccess( viewer: ServiceViewer, row: { ownerTeamId: string | null }, action: EnvironmentAction ): AccessDecision { - const eligible = - row.ownerTeamId === null || viewer.teamId === null || row.ownerTeamId === viewer.teamId; - if (!eligible) return deny("not_member"); + if (row.ownerTeamId !== null && row.ownerTeamId !== viewer.teamId) return deny("not_member"); return action === "read" || action === "use" ? permit() : deny("missing_permission"); } diff --git a/packages/shared/src/types/team-access.test.ts b/packages/shared/src/types/team-access.test.ts index c417a1d437..5359d28477 100644 --- a/packages/shared/src/types/team-access.test.ts +++ b/packages/shared/src/types/team-access.test.ts @@ -43,6 +43,7 @@ describe("resolveTeamAccess", () => { canManageRepositories: manages, canManageBindings: manages, canManageAutomations: manages, + canManageEnvironments: manages, canManageSecrets: manages, canArchive: manages, }); diff --git a/packages/shared/src/types/team-access.ts b/packages/shared/src/types/team-access.ts index 599584eab3..404f5b9f34 100644 --- a/packages/shared/src/types/team-access.ts +++ b/packages/shared/src/types/team-access.ts @@ -1,3 +1,4 @@ +import { isWorkspaceAdmin } from "../rbac"; import type { Team, TeamRole } from "./teams"; export interface TeamCapabilities { @@ -8,6 +9,7 @@ export interface TeamCapabilities { canManageRepositories: boolean; canManageBindings: boolean; canManageAutomations: boolean; + canManageEnvironments: boolean; canManageSecrets: boolean; canArchive: boolean; } @@ -17,8 +19,7 @@ export function resolveTeamAccess( team: Team & { leadCount: number } ): TeamCapabilities { const role = viewer.memberships.get(team.id); - const manages = - viewer.roleKey === "owner" || viewer.roleKey === "administrator" || role === "lead"; + const manages = isWorkspaceAdmin(viewer.roleKey) || role === "lead"; return { canJoin: role === undefined && team.joinPolicy === "open" && team.archivedAt === null, canLeave: role !== undefined && (role !== "lead" || team.leadCount > 1), @@ -27,6 +28,7 @@ export function resolveTeamAccess( canManageRepositories: manages, canManageBindings: manages, canManageAutomations: manages, + canManageEnvironments: manages, canManageSecrets: manages, canArchive: manages, }; diff --git a/packages/shared/src/types/team-id.ts b/packages/shared/src/types/team-id.ts new file mode 100644 index 0000000000..329ae982a0 --- /dev/null +++ b/packages/shared/src/types/team-id.ts @@ -0,0 +1,4 @@ +import { z } from "zod"; + +/** Team IDs as minted by the control plane (`team_`). */ +export const teamIdSchema = z.string().regex(/^team_[A-Za-z0-9_-]+$/); diff --git a/packages/shared/src/types/teams.ts b/packages/shared/src/types/teams.ts index b9cc169426..b54365758d 100644 --- a/packages/shared/src/types/teams.ts +++ b/packages/shared/src/types/teams.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { isEnvironmentId } from "./environments"; -import { sessionListRepositorySchema } from "./repositories"; +import { repositoryPairInputSchema, sessionListRepositorySchema } from "./repositories"; export const teamRoleSchema = z.enum(["lead", "member"]); export type TeamRole = z.infer; @@ -79,6 +79,7 @@ export const teamCapabilitiesSchema = z.object({ canManageRepositories: z.boolean(), canManageBindings: z.boolean(), canManageAutomations: z.boolean(), + canManageEnvironments: z.boolean(), canManageSecrets: z.boolean(), canArchive: z.boolean(), }); @@ -178,3 +179,42 @@ export const teamSessionsResponseSchema = z.union([ }), ]); export type TeamSessionsResponse = z.infer; + +export const MAX_TEAM_REPOSITORY_GRANTS = 500; + +export const addTeamRepositoryGrantRequestSchema = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("installation") }), + z.strictObject({ + kind: z.literal("repository"), + repoExternalId: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + owner: repositoryPairInputSchema.shape.repoOwner, + name: repositoryPairInputSchema.shape.repoName, + }), +]); +export type AddTeamRepositoryGrantRequest = z.infer; + +const grantFields = { + id: z.string(), + teamId: z.string(), + createdAt: z.number(), +}; +export const teamRepositoryGrantSchema = z.discriminatedUnion("kind", [ + z.object({ + ...grantFields, + kind: z.literal("installation"), + repoExternalId: z.null(), + owner: z.null(), + name: z.null(), + }), + z.object({ + ...grantFields, + kind: z.literal("repository"), + repoExternalId: z.number().int().positive(), + owner: z.string(), + name: z.string(), + }), +]); +export type TeamRepositoryGrant = z.infer; +export const teamRepositoryGrantsResponseSchema = z.object({ + grants: z.array(teamRepositoryGrantSchema), +}); diff --git a/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.test.tsx b/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.test.tsx index cf08e8764f..5fb341c86c 100644 --- a/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.test.tsx +++ b/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.test.tsx @@ -2,16 +2,26 @@ /// import { Suspense } from "react"; -import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import * as matchers from "@testing-library/jest-dom/matchers"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import EditAutomationPage from "./page"; +import type { AutomationCapabilities } from "@open-inspect/shared/types/automations"; +import type { AutomationFormValues } from "@/components/automations/automation-form"; +import { browserApiFetch } from "@/lib/browser-api-fetch"; +import { invalidateAutomationCache } from "@/lib/automation-cache"; expect.extend(matchers); const CURRENT_USER_ID = "11111111111111111111111111111111"; let permissions: string[] = []; const replace = vi.fn(); +const push = vi.fn(); +let search = ""; +const formProps = vi.fn(); +let submittedEnvironmentIds = ["env-1"]; + +const NO_CAPABILITIES = { canRead: false, canManage: false, canTrigger: false }; const automation = { id: "auto-1", @@ -33,16 +43,19 @@ const automation = { eventType: null, triggerConfig: null, repositories: [], - environmentIds: [], + environmentIds: new Array(), providerSelections: {}, + capabilities: NO_CAPABILITIES as AutomationCapabilities, + ownerTeamId: "team-1", }; vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: vi.fn(), replace }), + useRouter: () => ({ push, replace }), + useSearchParams: () => new URLSearchParams(search), })); vi.mock("@/components/sidebar-layout", () => ({ CollapsedSidebarControls: () => null, - useSidebarContext: () => ({ isOpen: true }), + useSidebarContext: () => ({ isOpen: false }), })); vi.mock("@/hooks/use-automations", () => ({ useAutomation: () => ({ automation, loading: false }), @@ -54,8 +67,37 @@ vi.mock("@/hooks/use-current-user-authorization", () => ({ }), })); vi.mock("@/components/automations/automation-form", () => ({ - AutomationForm: () =>
Automation edit form
, + AutomationForm: (props: { + onSubmit: (values: AutomationFormValues) => void; + initialValues: Partial; + }) => { + formProps(props); + return ( +
+ Automation edit form + +
+ ); + }, })); +vi.mock("@/lib/browser-api-fetch", () => ({ browserApiFetch: vi.fn() })); +vi.mock("@/lib/automation-cache", () => ({ invalidateAutomationCache: vi.fn() })); async function renderPage() { await act(async () => { @@ -69,24 +111,94 @@ async function renderPage() { beforeEach(() => { permissions = []; + search = ""; + formProps.mockClear(); + automation.capabilities = NO_CAPABILITIES; + automation.environmentIds = []; + submittedEnvironmentIds = ["env-1"]; replace.mockReset(); + push.mockReset(); + vi.mocked(invalidateAutomationCache).mockReset().mockResolvedValue(undefined); + vi.mocked(browserApiFetch).mockReset(); + vi.mocked(browserApiFetch).mockResolvedValue(Response.json({})); }); afterEach(cleanup); describe("EditAutomationPage authorization", () => { - it("redirects an unauthorized own-scoped deep link without rendering the form", async () => { - permissions = ["automations.manage.own"]; - await renderPage(); + it.each([ + { saved: ["env-1", "env-2"], submitted: ["env-2", "env-1"], unchanged: true }, + { saved: ["env-1"], submitted: [], unchanged: false }, + ])( + "only sends a replacement for changed environment IDs ($saved -> $submitted)", + async ({ saved, submitted, unchanged }) => { + automation.environmentIds = saved; + submittedEnvironmentIds = submitted; + automation.capabilities = { canRead: true, canManage: true, canTrigger: false }; + await renderPage(); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + await waitFor(() => expect(push).toHaveBeenCalledWith("/automations/auto-1")); + const body = JSON.parse(String(vi.mocked(browserApiFetch).mock.calls[0][1]?.body)); + if (unchanged) expect(body).not.toHaveProperty("environmentIds"); + else expect(body.environmentIds).toEqual(submitted); + expect(body.repositories).toEqual([{ repoOwner: "acme", repoName: "app" }]); + } + ); - await waitFor(() => expect(replace).toHaveBeenCalledWith("/automations/auto-1")); - expect(screen.queryByText("Automation edit form")).not.toBeInTheDocument(); + it("preserves navigation scope on back and save while editing the original owner", async () => { + search = "teamId=team%2Fone"; + automation.capabilities = { canRead: true, canManage: true, canTrigger: false }; + await renderPage(); + expect(screen.getByRole("link", { name: "Back to automation" })).toHaveAttribute( + "href", + "/automations/auto-1?teamId=team%2Fone" + ); + expect(formProps).toHaveBeenLastCalledWith( + expect.objectContaining({ initialValues: expect.objectContaining({ teamId: "team-1" }) }) + ); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + await waitFor(() => expect(push).toHaveBeenCalledWith("/automations/auto-1?teamId=team%2Fone")); + expect(browserApiFetch).toHaveBeenCalledWith( + "/api/automations/auto-1", + expect.objectContaining({ method: "PUT" }) + ); + const body = JSON.parse(String(vi.mocked(browserApiFetch).mock.calls[0][1]?.body)); + expect(body).not.toHaveProperty("teamId"); + expect(body.environmentIds).toEqual(["env-1"]); + expect(invalidateAutomationCache).toHaveBeenCalledWith(expect.anything(), "auto-1"); }); - it("renders the form with automations.manage.any", async () => { - permissions = ["automations.manage.any"]; + it.each(["", "?teamId=team%2Fone"])( + "redirects missing capabilities with global manage and scope %s", + async (query) => { + search = query.slice(1); + permissions = ["automations.manage.any"]; + await renderPage(); + + await waitFor(() => expect(replace).toHaveBeenCalledWith(`/automations/auto-1${query}`)); + expect(screen.queryByText("Automation edit form")).not.toBeInTheDocument(); + } + ); + + it("renders the form with server canManage", async () => { + automation.capabilities = { canRead: true, canManage: true, canTrigger: false }; await renderPage(); expect(await screen.findByText("Automation edit form")).toBeInTheDocument(); expect(replace).not.toHaveBeenCalled(); }); + + it("reports configuration failures without navigating away", async () => { + automation.capabilities = { canRead: true, canManage: true, canTrigger: false }; + vi.mocked(browserApiFetch).mockResolvedValueOnce( + Response.json({ error: "Update denied" }, { status: 403 }) + ); + await renderPage(); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Update denied"); + expect(vi.mocked(browserApiFetch).mock.calls.map(([path]) => path)).toEqual([ + "/api/automations/auto-1", + ]); + expect(push).not.toHaveBeenCalled(); + expect(invalidateAutomationCache).not.toHaveBeenCalled(); + }); }); diff --git a/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.tsx b/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.tsx index 7cb2b57674..9aa5cbef7e 100644 --- a/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.tsx @@ -12,52 +12,57 @@ import { import { ErrorBanner } from "@/components/ui/error-banner"; import { BackIcon } from "@/components/ui/icons"; import { browserApiFetch } from "@/lib/browser-api-fetch"; -import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; -import { canAccessAutomation } from "@/lib/automation-authorization"; +import { useSWRConfig } from "swr"; +import { invalidateAutomationCache } from "@/lib/automation-cache"; +import { useAutomationScope } from "@/hooks/use-automation-scope"; +import { sameEnvironmentIds } from "@/components/automations/automation-target-selection"; export default function EditAutomationPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); const { isOpen } = useSidebarContext(); const router = useRouter(); + const { navigation } = useAutomationScope(); const { automation, loading } = useAutomation(id); - const { authorization, loading: authorizationLoading } = useCurrentUserAuthorization(); + const swr = useSWRConfig(); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(""); - const canManage = automation - ? canAccessAutomation("automations.manage", authorization, automation) - : false; + const canManage = automation?.capabilities.canManage ?? false; useEffect(() => { - if (!loading && !authorizationLoading && automation && !canManage) { - router.replace(`/automations/${id}`); + if (!loading && automation && !canManage) { + router.replace(navigation.detail(id)); } - }, [automation, authorizationLoading, canManage, id, loading, router]); + }, [automation, canManage, id, loading, navigation, router]); const handleSubmit = async (values: AutomationFormValues) => { + if (!canManage || !automation) return; setSubmitting(true); setError(""); try { + const { environmentIds, ...otherValues } = values; const res = await browserApiFetch(`/api/automations/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(values), + body: JSON.stringify( + sameEnvironmentIds(environmentIds, automation.environmentIds) ? otherValues : values + ), }); - if (res.ok) { - router.push(`/automations/${id}`); - } else { + if (!res.ok) { const data = await res.json(); - setError(data.error || "Failed to update automation"); - setSubmitting(false); + throw new Error(data.error || "Failed to update automation"); } - } catch { - setError("Failed to update automation"); + await invalidateAutomationCache(swr, id); + router.push(navigation.detail(id)); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Failed to update automation"); + } finally { setSubmitting(false); } }; - if (loading || authorizationLoading) { + if (loading) { return (
@@ -69,7 +74,7 @@ export default function EditAutomationPage({ params }: { params: Promise<{ id: s return (

Automation not found.

- + @@ -87,7 +92,7 @@ export default function EditAutomationPage({ params }: { params: Promise<{ id: s
@@ -112,6 +117,7 @@ export default function EditAutomationPage({ params }: { params: Promise<{ id: s import { Suspense } from "react"; -import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import * as matchers from "@testing-library/jest-dom/matchers"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { MAX_AUTOMATION_INVOCATION_LIST_LIMIT } from "@open-inspect/shared/types/automations"; +import { + MAX_AUTOMATION_INVOCATION_LIST_LIMIT, + type AutomationCapabilities, +} from "@open-inspect/shared/types/automations"; import AutomationDetailPage from "./page"; +import { browserApiFetch } from "@/lib/browser-api-fetch"; expect.extend(matchers); const CURRENT_USER_ID = "11111111111111111111111111111111"; const OTHER_USER_ID = "22222222222222222222222222222222"; let permissions: string[] = []; +let search = ""; +const push = vi.fn(); /** How many invocations the automation has, and every limit the page asked for. */ const history = vi.hoisted(() => ({ total: 0, requestedLimits: [] as number[] })); +/** Set when a mutation has evicted the cached automation. */ +const detail = vi.hoisted(() => ({ evicted: false })); + +const NO_CAPABILITIES = { canRead: false, canManage: false, canTrigger: false }; const automation = { id: "auto-1", @@ -39,18 +49,27 @@ const automation = { repositories: [], environmentIds: [], providerSelections: {}, + capabilities: NO_CAPABILITIES as AutomationCapabilities, }; -vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push }), + useSearchParams: () => new URLSearchParams(search), +})); +vi.mock("@/lib/browser-api-fetch", () => ({ browserApiFetch: vi.fn() })); vi.mock("next/link", () => ({ default: ({ children, ...props }: React.ComponentProps<"a">) => {children}, })); vi.mock("@/components/sidebar-layout", () => ({ CollapsedSidebarControls: () => null, - useSidebarContext: () => ({ isOpen: true }), + useSidebarContext: () => ({ isOpen: false }), })); vi.mock("@/hooks/use-automations", () => ({ - useAutomation: () => ({ automation, loading: false, mutate: vi.fn() }), + useAutomation: () => ({ + automation: detail.evicted ? undefined : automation, + loading: false, + mutate: vi.fn(), + }), useAutomationInvocations: (_id: string, limit: number) => { history.requestedLimits.push(limit); return { @@ -79,20 +98,31 @@ vi.mock("@/components/automations/run-history", () => ({ hasMore ? : null, })); +const params = Promise.resolve({ id: "auto-1" }); +const page = () => ( + + + +); + async function renderPage() { + let rendered!: ReturnType; await act(async () => { - render( - - - - ); + rendered = render(page()); }); + return rendered; } beforeEach(() => { permissions = []; + search = ""; + push.mockReset(); + vi.mocked(browserApiFetch).mockReset(); + vi.mocked(browserApiFetch).mockResolvedValue(Response.json({})); + automation.capabilities = NO_CAPABILITIES; history.total = 0; history.requestedLimits = []; + detail.evicted = false; }); afterEach(cleanup); @@ -117,8 +147,54 @@ describe("AutomationDetailPage run history", () => { }); describe("AutomationDetailPage authorization", () => { - it("does not treat createdBy provenance as canonical ownership", async () => { - permissions = ["automations.manage.own", "automations.trigger.own"]; + it.each([undefined, "team/one"])( + "preserves scope %s on back, edit, and delete", + async (teamId) => { + search = teamId ? new URLSearchParams({ teamId }).toString() : ""; + const query = teamId ? "?teamId=team%2Fone" : ""; + automation.capabilities = { canRead: true, canManage: true, canTrigger: true }; + await renderPage(); + expect(screen.getByRole("link", { name: "Back to automations" })).toHaveAttribute( + "href", + `/automations${query}` + ); + expect(screen.getByRole("link", { name: "Edit" })).toHaveAttribute( + "href", + `/automations/auto-1/edit${query}` + ); + fireEvent.click(screen.getByRole("button", { name: "Delete" })); + fireEvent.click(screen.getByRole("button", { name: "Confirm Delete" })); + await waitFor(() => expect(push).toHaveBeenCalledWith(`/automations${query}`)); + } + ); + + it("does not flash not-found while a deleted automation navigates away", async () => { + automation.capabilities = { canRead: true, canManage: true, canTrigger: true }; + vi.mocked(browserApiFetch).mockImplementation(() => { + detail.evicted = true; + return new Promise(() => {}); + }); + const { rerender } = await renderPage(); + fireEvent.click(screen.getByRole("button", { name: "Delete" })); + fireEvent.click(screen.getByRole("button", { name: "Confirm Delete" })); + await waitFor(() => expect(browserApiFetch).toHaveBeenCalled()); + // The cache eviction re-renders the page before navigation completes. + rerender(page()); + expect(screen.queryByText("Automation not found.")).not.toBeInTheDocument(); + }); + + it("reports a failed delete without leaving its scope", async () => { + automation.capabilities = { canRead: true, canManage: true, canTrigger: false }; + vi.mocked(browserApiFetch).mockResolvedValue(Response.json({}, { status: 403 })); + await renderPage(); + fireEvent.click(screen.getByRole("button", { name: "Delete" })); + fireEvent.click(screen.getByRole("button", { name: "Confirm Delete" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Failed to delete automation"); + expect(push).not.toHaveBeenCalled(); + }); + + it("does not infer resource capabilities from global permissions", async () => { + permissions = ["automations.manage.any", "automations.trigger.any"]; await renderPage(); await screen.findByRole("heading", { name: "Nightly review" }); @@ -128,8 +204,8 @@ describe("AutomationDetailPage authorization", () => { expect(screen.queryByRole("button", { name: "Delete" })).not.toBeInTheDocument(); }); - it("shows manage and trigger controls with any-scoped capabilities", async () => { - permissions = ["automations.manage.any", "automations.trigger.any"]; + it("shows manage and trigger controls using response capabilities", async () => { + automation.capabilities = { canRead: true, canManage: true, canTrigger: true }; await renderPage(); await screen.findByRole("heading", { name: "Nightly review" }); diff --git a/packages/web/src/app/(app)/(sidebar)/automations/[id]/page.tsx b/packages/web/src/app/(app)/(sidebar)/automations/[id]/page.tsx index c5c80718d4..37191d5092 100644 --- a/packages/web/src/app/(app)/(sidebar)/automations/[id]/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/automations/[id]/page.tsx @@ -18,9 +18,8 @@ import { BackIcon, PencilIcon } from "@/components/ui/icons"; import { formatModelNameLower } from "@/lib/format"; import { getHarnessLabel } from "@open-inspect/shared/harnesses"; import { formatAutomationTargetsLabel } from "@/lib/repo-label"; -import { browserApiFetch } from "@/lib/browser-api-fetch"; -import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; -import { canAccessAutomation } from "@/lib/automation-authorization"; +import { useAutomationActions } from "@/hooks/use-automation-actions"; +import { useAutomationScope } from "@/hooks/use-automation-scope"; const HISTORY_PAGE_SIZE = 20; @@ -28,9 +27,9 @@ export default function AutomationDetailPage({ params }: { params: Promise<{ id: const { id } = use(params); const { isOpen } = useSidebarContext(); const router = useRouter(); - const { automation, loading, mutate } = useAutomation(id); - const { authorization } = useCurrentUserAuthorization(); - const { environments } = useEnvironments(); + const { navigation } = useAutomationScope(); + const { automation, loading } = useAutomation(id); + const { environments } = useEnvironments({ ownerTeamId: automation?.ownerTeamId }); // "Load more" grows the fetch limit rather than paging by offset: the // endpoint returns newest-first, so a larger limit re-fetches the head plus // the next page in one request. The endpoint refuses limits past its @@ -45,47 +44,24 @@ export default function AutomationDetailPage({ params }: { params: Promise<{ id: invocations, total: totalInvocations, loading: loadingInvocations, - mutate: mutateInvocations, } = useAutomationInvocations(id, historyLimit, 0); const [confirmDelete, setConfirmDelete] = useState(false); - const [actionError, setActionError] = useState(null); + const { act, actionError } = useAutomationActions(); const reasoningLabel = automation ? (automation.reasoningEffort ?? (getReasoningConfig(automation.model) ? "Model default" : "Not supported")) : null; - const handleAction = async (action: "pause" | "resume" | "trigger") => { - setActionError(null); - try { - const res = await browserApiFetch(`/api/automations/${id}/${action}`, { method: "POST" }); - if (!res.ok) { - setActionError(`Failed to ${action} automation`); - return; - } - mutate(); - mutateInvocations(); - } catch (error) { - console.error(`Failed to ${action} automation:`, error); - setActionError(`Failed to ${action} automation`); - } - }; - + // Deletion evicts the cached automation before navigation; keep the spinner up + // meanwhile instead of flashing "not found". + const [deleting, setDeleting] = useState(false); const handleDelete = async () => { - setActionError(null); - try { - const res = await browserApiFetch(`/api/automations/${id}`, { method: "DELETE" }); - if (!res.ok) { - setActionError("Failed to delete automation"); - return; - } - router.push("/automations"); - } catch (error) { - console.error("Failed to delete automation:", error); - setActionError("Failed to delete automation"); - } + setDeleting(true); + if (await act(id, "delete")) router.push(navigation.list); + else setDeleting(false); }; - if (loading) { + if (loading || (deleting && !automation)) { return (
@@ -97,7 +73,7 @@ export default function AutomationDetailPage({ params }: { params: Promise<{ id: return (

Automation not found.

- + @@ -106,8 +82,7 @@ export default function AutomationDetailPage({ params }: { params: Promise<{ id: ); } - const canManage = canAccessAutomation("automations.manage", authorization, automation); - const canTrigger = canAccessAutomation("automations.trigger", authorization, automation); + const { canManage, canTrigger } = automation.capabilities; return (
@@ -116,7 +91,7 @@ export default function AutomationDetailPage({ params }: { params: Promise<{ id:
@@ -153,7 +128,7 @@ export default function AutomationDetailPage({ params }: { params: Promise<{ id:
{canManage && ( - + @@ -178,7 +153,7 @@ export default function AutomationDetailPage({ params }: { params: Promise<{ id: variant="outline" size="sm" className="w-full sm:w-auto" - onClick={() => handleAction("pause")} + onClick={() => void act(id, "pause")} > Pause @@ -187,7 +162,7 @@ export default function AutomationDetailPage({ params }: { params: Promise<{ id: variant="outline" size="sm" className="w-full sm:w-auto" - onClick={() => handleAction("resume")} + onClick={() => void act(id, "resume")} > Resume diff --git a/packages/web/src/app/(app)/(sidebar)/automations/new/page.test.tsx b/packages/web/src/app/(app)/(sidebar)/automations/new/page.test.tsx index f237d7dc17..e522dc8c1c 100644 --- a/packages/web/src/app/(app)/(sidebar)/automations/new/page.test.tsx +++ b/packages/web/src/app/(app)/(sidebar)/automations/new/page.test.tsx @@ -1,12 +1,15 @@ // @vitest-environment jsdom /// -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import * as matchers from "@testing-library/jest-dom/matchers"; import type { ReactNode } from "react"; import { DEFAULT_MODEL } from "@open-inspect/shared/models"; import NewAutomationPage from "./page"; +import { browserApiFetch } from "@/lib/browser-api-fetch"; +import { invalidateAutomationCache } from "@/lib/automation-cache"; expect.extend(matchers); afterEach(cleanup); @@ -16,12 +19,25 @@ let search = ""; let enabledModelsValue: string[] = [DEFAULT_MODEL, "anthropic/claude-opus-4-8", "openai/gpt-5.5"]; let canCreate = true; const replace = vi.fn(); +const push = vi.fn(); vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(search), - useRouter: () => ({ push: vi.fn(), replace }), + useRouter: () => ({ push, replace }), +})); +vi.mock("@/lib/browser-api-fetch", () => ({ browserApiFetch: vi.fn() })); +vi.mock("@/lib/automation-cache", () => ({ invalidateAutomationCache: vi.fn() })); +vi.mock("@/components/automations/webhook-config", () => ({ + WebhookConfig: () =>
Webhook configuration
, })); +vi.mock("@/hooks/use-teams", () => ({ + useMeTeams: () => ({ + teams: [{ id: "team-1" }, { id: "team-2" }, { id: "team/one" }], + loading: false, + error: undefined, + }), +})); vi.mock("@/hooks/use-current-user-authorization", () => ({ useCurrentUserAuthorization: () => ({ hasPermission: (permission: string) => permission === "automations.create" && canCreate, @@ -30,7 +46,8 @@ vi.mock("@/hooks/use-current-user-authorization", () => ({ })); vi.mock("@/components/sidebar-layout", () => ({ - useSidebarContext: () => ({ isOpen: true, toggle: vi.fn() }), + CollapsedSidebarControls: () => null, + useSidebarContext: () => ({ isOpen: false, toggle: vi.fn() }), })); vi.mock("@/hooks/use-repos", () => ({ @@ -40,6 +57,24 @@ vi.mock("@/hooks/use-repos", () => ({ vi.mock("@/hooks/use-environments", () => ({ useEnvironments: () => ({ environments: [], loading: false }), })); +vi.mock("@/hooks/use-resource-teams", () => ({ + useResourceTeams: () => ({ + teams: [ + { id: "team-1", name: "Engineering" }, + { id: "team-2", name: "Design" }, + ], + allTeams: [ + { id: "team-1", name: "Engineering" }, + { id: "team-2", name: "Design" }, + ], + loading: false, + error: null, + allowWorkspace: true, + }), +})); +vi.mock("@/hooks/use-provider-accounts", () => ({ + useProviderAccounts: () => ({ accounts: [], defaults: [], loading: false }), +})); vi.mock("@/hooks/use-branches", () => ({ useBranches: () => ({ branches: [], loading: false }), @@ -69,14 +104,104 @@ beforeEach(() => { enabledModelsValue = [DEFAULT_MODEL, "anthropic/claude-opus-4-8", "openai/gpt-5.5"]; canCreate = true; replace.mockReset(); + push.mockReset(); + vi.mocked(invalidateAutomationCache).mockReset().mockResolvedValue(undefined); + vi.mocked(browserApiFetch).mockReset(); + vi.mocked(browserApiFetch).mockResolvedValue(Response.json({ automation: { id: "new-auto" } })); }); +beforeAll(() => { + // Radix Select uses pointer capture, which jsdom lacks. + Element.prototype.hasPointerCapture = () => false; + Element.prototype.releasePointerCapture = () => {}; + Element.prototype.scrollIntoView = vi.fn(); +}); + +async function chooseTeam(user: ReturnType, name: string) { + await user.click(screen.getByRole("combobox", { name: "Team" })); + await user.click(await screen.findByRole("option", { name })); +} + describe("NewAutomationPage template pre-fill", () => { - it("redirects a direct create link without automations.create", () => { + it.each([undefined, "team-1"])( + "keeps navigation scope %s when the selected creation owner changes", + async (teamId) => { + search = `template=find-bugs${teamId ? `&teamId=${teamId}` : ""}`; + const user = userEvent.setup(); + const { container } = render(); + expect(screen.getByDisplayValue("Find bugs")).toBeInTheDocument(); + expect(screen.getByRole("combobox", { name: "Team" })).toHaveTextContent( + teamId ? "Engineering" : "Workspace (no team)" + ); + await chooseTeam(user, teamId ? "Workspace (no team)" : "Engineering"); + const scopeQuery = teamId ? "?teamId=team-1" : ""; + expect(screen.getByRole("link", { name: "Back to automations" })).toHaveAttribute( + "href", + `/automations${scopeQuery}` + ); + fireEvent.submit(container.querySelector("form")!); + await waitFor(() => expect(push).toHaveBeenCalledWith(`/automations/new-auto${scopeQuery}`)); + const body = JSON.parse(String(vi.mocked(browserApiFetch).mock.calls[0][1]?.body)); + expect(body.teamId).toBe(teamId ? null : "team-1"); + expect(vi.mocked(browserApiFetch).mock.calls[0][1]?.method).toBe("POST"); + expect(invalidateAutomationCache).toHaveBeenCalledWith(expect.anything()); + } + ); + + it("follows an in-place change of the query-selected team", () => { + search = "teamId=team-1"; + const view = render(); + expect(screen.getByRole("combobox", { name: "Team" })).toHaveTextContent("Engineering"); + search = "teamId=team-2"; + view.rerender(); + expect(screen.getByRole("combobox", { name: "Team" })).toHaveTextContent("Design"); + expect(screen.getByRole("link", { name: "Back to automations" })).toHaveAttribute( + "href", + "/automations?teamId=team-2" + ); + }); + + it("treats the API's workspace filter sentinel as no team scope", () => { + search = "teamId=null"; + render(); + expect(screen.getByRole("combobox", { name: "Team" })).toHaveTextContent("Workspace (no team)"); + expect(screen.getByRole("link", { name: "Back to automations" })).toHaveAttribute( + "href", + "/automations" + ); + }); + + it("preserves scope after webhook creation while allowing a different owner", async () => { + search = "template=find-bugs&teamId=team-1"; + vi.mocked(browserApiFetch).mockResolvedValue( + Response.json({ automation: { id: "new-auto" }, webhookApiKey: "secret" }) + ); + const user = userEvent.setup(); + const { container } = render(); + await chooseTeam(user, "Workspace (no team)"); + fireEvent.submit(container.querySelector("form")!); + expect(await screen.findByRole("link", { name: "Go to Automation" })).toHaveAttribute( + "href", + "/automations/new-auto?teamId=team-1" + ); + expect(JSON.parse(String(vi.mocked(browserApiFetch).mock.calls[0][1]?.body)).teamId).toBe(null); + expect(push).not.toHaveBeenCalled(); + }); + + it.each(["", "?teamId=team%2Fone"])("redirects a denied create link with scope %s", (query) => { + search = query.slice(1); canCreate = false; render(); - expect(replace).toHaveBeenCalledWith("/automations"); + expect(replace).toHaveBeenCalledWith(`/automations${query}`); + expect(screen.queryByRole("heading", { name: "Create Automation" })).not.toBeInTheDocument(); + }); + + it("redirects a team-scoped create link for a team the user does not belong to", () => { + search = "teamId=team-3"; + render(); + + expect(replace).toHaveBeenCalledWith("/automations?teamId=team-3"); expect(screen.queryByRole("heading", { name: "Create Automation" })).not.toBeInTheDocument(); }); diff --git a/packages/web/src/app/(app)/(sidebar)/automations/new/page.tsx b/packages/web/src/app/(app)/(sidebar)/automations/new/page.tsx index 3116bdd5ad..dbfdc534d3 100644 --- a/packages/web/src/app/(app)/(sidebar)/automations/new/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/automations/new/page.tsx @@ -14,14 +14,18 @@ import { ErrorBanner } from "@/components/ui/error-banner"; import { BackIcon } from "@/components/ui/icons"; import { browserApiFetch } from "@/lib/browser-api-fetch"; import Link from "next/link"; -import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; +import { useSWRConfig } from "swr"; +import { invalidateAutomationCache } from "@/lib/automation-cache"; +import { useAutomationScope } from "@/hooks/use-automation-scope"; +import { useCanCreateAutomation } from "@/hooks/use-can-create-automation"; function NewAutomationContent() { const { isOpen } = useSidebarContext(); const router = useRouter(); const searchParams = useSearchParams(); - const { hasPermission, loading: authorizationLoading } = useCurrentUserAuthorization(); - const canCreate = hasPermission("automations.create"); + const { teamId, navigation } = useAutomationScope(); + const swr = useSWRConfig(); + const { canCreate, loading: authorizationLoading } = useCanCreateAutomation(teamId); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(""); @@ -33,8 +37,8 @@ function NewAutomationContent() { } | null>(null); useEffect(() => { - if (!authorizationLoading && !canCreate) router.replace("/automations"); - }, [authorizationLoading, canCreate, router]); + if (!authorizationLoading && !canCreate) router.replace(navigation.list); + }, [authorizationLoading, canCreate, navigation, router]); if (authorizationLoading || !canCreate) return null; @@ -43,7 +47,10 @@ function NewAutomationContent() { // form coerces a template's suggested model against the user's enabled set. const templateId = searchParams.get("template"); const template = automationTemplates.find((candidate) => candidate.id === templateId); - const initialValues: Partial | undefined = template?.prefill; + const initialValues: Partial = { + ...template?.prefill, + teamId: teamId ?? null, + }; const handleSubmit = async (values: AutomationFormValues) => { setSubmitting(true); @@ -58,6 +65,7 @@ function NewAutomationContent() { if (res.ok) { const data = await res.json(); + await invalidateAutomationCache(swr); // For webhook/sentry automations, show post-create info before navigating if (data.webhookApiKey || data.sentryWebhookUrl) { setWebhookResult({ @@ -68,7 +76,7 @@ function NewAutomationContent() { }); setSubmitting(false); } else { - router.push(`/automations/${data.automation.id}`); + router.push(navigation.detail(data.automation.id)); } } else { const data = await res.json(); @@ -123,7 +131,7 @@ function NewAutomationContent() { )}
- +
@@ -140,7 +148,7 @@ function NewAutomationContent() {
@@ -170,7 +178,9 @@ function NewAutomationContent() {
)} + {/* Remount when the query-selected team changes so the form's team follows the URL. */} ({ hasPermission: (permission: string) => mockPermissions.has(permission), }), })); +vi.mock("@/hooks/use-teams", () => ({ + useMeTeams: () => ({ teams: [{ id: "team/one" }], loading: false, error: undefined }), +})); vi.mock("@/components/automations/automations-list", () => ({ AutomationsList: ({ automations }: { automations: Array<{ name: string }> }) => ( @@ -84,14 +87,14 @@ describe("AutomationsPage", () => { target: { value: "release" }, }); - expect(mockUseAutomations).toHaveBeenLastCalledWith(""); + expect(mockUseAutomations).toHaveBeenLastCalledWith("", undefined); act(() => vi.runOnlyPendingTimers()); expect(mockReplace).toHaveBeenCalledWith("/automations?search=release", { scroll: false }); mockSearchParamsState.value = new URLSearchParams({ search: "release" }); rerender(); - expect(mockUseAutomations).toHaveBeenLastCalledWith("release"); + expect(mockUseAutomations).toHaveBeenLastCalledWith("release", undefined); }); it("shows retry and load-more controls for their respective states", () => { @@ -136,7 +139,7 @@ describe("AutomationsPage", () => { expect(screen.getByRole("searchbox", { name: "Search automations by name" })).toHaveValue( "weekly" ); - expect(mockUseAutomations).toHaveBeenLastCalledWith("weekly"); + expect(mockUseAutomations).toHaveBeenLastCalledWith("weekly", undefined); }); it("hides create and template entry points without automations.create", () => { @@ -146,4 +149,18 @@ describe("AutomationsPage", () => { expect(screen.queryByRole("link", { name: "Browse templates" })).not.toBeInTheDocument(); expect(screen.queryByRole("link", { name: "Create Automation" })).not.toBeInTheDocument(); }); + + it("forwards a team filter and preserves creation context", () => { + mockSearchParamsState.value = new URLSearchParams({ teamId: "team/one" }); + render(); + expect(mockUseAutomations).toHaveBeenLastCalledWith("", "team/one"); + expect(screen.getByRole("link", { name: "Create Automation" })).toHaveAttribute( + "href", + "/automations/new?teamId=team%2Fone" + ); + expect(screen.getByRole("link", { name: "Browse templates" })).toHaveAttribute( + "href", + "/automations/templates?teamId=team%2Fone" + ); + }); }); diff --git a/packages/web/src/app/(app)/(sidebar)/automations/page.tsx b/packages/web/src/app/(app)/(sidebar)/automations/page.tsx index 0481a6f965..e3dc968512 100644 --- a/packages/web/src/app/(app)/(sidebar)/automations/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/automations/page.tsx @@ -4,14 +4,11 @@ import { Suspense, useEffect, useState } from "react"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { CollapsedSidebarControls, useSidebarContext } from "@/components/sidebar-layout"; -import { useAutomations } from "@/hooks/use-automations"; -import { AutomationsList } from "@/components/automations/automations-list"; +import { AutomationCollection } from "@/components/automations/automation-collection"; import { Button } from "@/components/ui/button"; -import { ErrorBanner } from "@/components/ui/error-banner"; import { Input } from "@/components/ui/input"; import { PlusIcon, SearchIcon } from "@/components/ui/icons"; -import { browserApiFetch, type BrowserApiPath } from "@/lib/browser-api-fetch"; -import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; +import { useAutomationScope } from "@/hooks/use-automation-scope"; const SEARCH_DEBOUNCE_MS = 300; @@ -30,13 +27,8 @@ function AutomationsContent() { const searchParams = useSearchParams(); const urlNameSearch = searchParams.get("search") ?? ""; const committedNameSearch = urlNameSearch.trim(); + const { teamId, navigation } = useAutomationScope(); const [nameSearch, setNameSearch] = useState(urlNameSearch); - const { automations, loading, loadingMore, error, hasMore, loadMore, mutate } = - useAutomations(committedNameSearch); - const { hasPermission } = useCurrentUserAuthorization(); - const canCreate = hasPermission("automations.create"); - - const [actionError, setActionError] = useState(null); useEffect(() => { setNameSearch(urlNameSearch); @@ -62,25 +54,6 @@ function AutomationsContent() { return () => window.clearTimeout(debounceTimeoutId); }, [nameSearch, pathname, router, searchParams]); - const handleAction = async (id: string, action: "pause" | "resume" | "trigger" | "delete") => { - setActionError(null); - const endpoint: BrowserApiPath = - action === "delete" ? `/api/automations/${id}` : `/api/automations/${id}/${action}`; - const method = action === "delete" ? "DELETE" : "POST"; - - try { - const res = await browserApiFetch(endpoint, { method }); - if (!res.ok) { - setActionError(`Failed to ${action} automation`); - return; - } - mutate(); - } catch (error) { - console.error(`Failed to ${action} automation:`, error); - setActionError(`Failed to ${action} automation`); - } - }; - return (
{!isOpen && ( @@ -93,87 +66,46 @@ function AutomationsContent() {
-
-

Automations

- {canCreate && ( -
- - -
+ + {(canCreate) => ( + <> +
+

+ Automations +

+ {canCreate && ( +
+ + +
+ )} +
+ +
+
+ )} -
- -
-
- - {actionError && ( - - {actionError} - - )} - - {error && ( - -
- Failed to load automations. - -
-
- )} - - {loading ? ( -
-
-
- ) : automations.length > 0 || !error ? ( - handleAction(id, "pause")} - onResume={(id) => handleAction(id, "resume")} - onTrigger={(id) => handleAction(id, "trigger")} - onDelete={(id) => handleAction(id, "delete")} - /> - ) : null} - - {(hasMore || loadingMore) && !loading && ( -
- -
- )} +
diff --git a/packages/web/src/app/(app)/(sidebar)/automations/templates/page.test.tsx b/packages/web/src/app/(app)/(sidebar)/automations/templates/page.test.tsx index c99c56086e..ac326acf43 100644 --- a/packages/web/src/app/(app)/(sidebar)/automations/templates/page.test.tsx +++ b/packages/web/src/app/(app)/(sidebar)/automations/templates/page.test.tsx @@ -9,12 +9,21 @@ import AutomationTemplatesPage from "./page"; expect.extend(matchers); let canCreate = true; +let search = ""; const replace = vi.fn(); vi.mock("next/navigation", () => ({ useRouter: () => ({ replace }), + useSearchParams: () => new URLSearchParams(search), })); +vi.mock("@/hooks/use-teams", () => ({ + useMeTeams: () => ({ + teams: [{ id: "team-1" }, { id: "team-2" }, { id: "team/one" }], + loading: false, + error: undefined, + }), +})); vi.mock("@/hooks/use-current-user-authorization", () => ({ useCurrentUserAuthorization: () => ({ hasPermission: (permission: string) => permission === "automations.create" && canCreate, @@ -24,31 +33,42 @@ vi.mock("@/hooks/use-current-user-authorization", () => ({ vi.mock("@/components/sidebar-layout", () => ({ CollapsedSidebarControls: () => null, - useSidebarContext: () => ({ isOpen: true }), -})); - -vi.mock("@/components/automations/template-gallery", () => ({ - TemplateGallery: () =>
Template gallery
, + useSidebarContext: () => ({ isOpen: false }), })); beforeEach(() => { canCreate = true; + search = ""; replace.mockReset(); }); afterEach(cleanup); describe("AutomationTemplatesPage", () => { + it("preserves scope for template creation and return links", () => { + search = "teamId=team%2Fone"; + render(); + expect(screen.getByRole("link", { name: "Back to automations" })).toHaveAttribute( + "href", + "/automations?teamId=team%2Fone" + ); + expect(screen.getByRole("link", { name: "Add Find bugs" })).toHaveAttribute( + "href", + "/automations/new?template=find-bugs&teamId=team%2Fone" + ); + }); + it("renders templates with automations.create", () => { render(); expect(screen.getByRole("heading", { name: "Automation templates" })).toBeInTheDocument(); }); - it("redirects a direct template link without automations.create", () => { + it.each(["", "?teamId=team%2Fone"])("redirects a denied template link with scope %s", (query) => { + search = query.slice(1); canCreate = false; render(); - expect(replace).toHaveBeenCalledWith("/automations"); + expect(replace).toHaveBeenCalledWith(`/automations${query}`); expect(screen.queryByRole("heading", { name: "Automation templates" })).not.toBeInTheDocument(); }); }); diff --git a/packages/web/src/app/(app)/(sidebar)/automations/templates/page.tsx b/packages/web/src/app/(app)/(sidebar)/automations/templates/page.tsx index 56d3936c9f..6a627b6c35 100644 --- a/packages/web/src/app/(app)/(sidebar)/automations/templates/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/automations/templates/page.tsx @@ -1,22 +1,31 @@ "use client"; import Link from "next/link"; -import { useEffect } from "react"; +import { Suspense, useEffect } from "react"; import { useRouter } from "next/navigation"; import { CollapsedSidebarControls, useSidebarContext } from "@/components/sidebar-layout"; import { TemplateGallery } from "@/components/automations/template-gallery"; import { BackIcon } from "@/components/ui/icons"; -import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; +import { useAutomationScope } from "@/hooks/use-automation-scope"; +import { useCanCreateAutomation } from "@/hooks/use-can-create-automation"; export default function AutomationTemplatesPage() { + return ( + + + + ); +} + +function AutomationTemplatesContent() { const { isOpen } = useSidebarContext(); const router = useRouter(); - const { hasPermission, loading } = useCurrentUserAuthorization(); - const canCreate = hasPermission("automations.create"); + const { teamId, navigation } = useAutomationScope(); + const { canCreate, loading } = useCanCreateAutomation(teamId); useEffect(() => { - if (!loading && !canCreate) router.replace("/automations"); - }, [canCreate, loading, router]); + if (!loading && !canCreate) router.replace(navigation.list); + }, [canCreate, loading, navigation, router]); if (loading || !canCreate) return null; @@ -27,7 +36,7 @@ export default function AutomationTemplatesPage() {
@@ -49,7 +58,7 @@ export default function AutomationTemplatesPage() {

- +
diff --git a/packages/web/src/app/(app)/(sidebar)/session/[id]/page.test.tsx b/packages/web/src/app/(app)/(sidebar)/session/[id]/page.test.tsx index 6b095c037f..1b7cc88739 100644 --- a/packages/web/src/app/(app)/(sidebar)/session/[id]/page.test.tsx +++ b/packages/web/src/app/(app)/(sidebar)/session/[id]/page.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { Component, type PropsWithChildren, type ReactNode } from "react"; -import { cleanup, render, screen } from "@testing-library/react"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import type { SessionSnapshot } from "@open-inspect/shared/types/server-messages"; import { resolveSessionCapabilities } from "@/lib/session-capabilities"; @@ -153,6 +153,7 @@ class NotFoundBoundary extends Component { cleanup(); + localStorage.clear(); vi.restoreAllMocks(); }); @@ -171,6 +172,19 @@ it("renders the existing not-found path before cached session content or action expect(mocks.prompt).not.toHaveBeenCalled(); }); +it("opens phone media on Info without replacing the remembered inspector tab", async () => { + localStorage.setItem("open-inspect-session-inspector-tab", "changes"); + mocks.mobile = true; + render(); + await waitFor(() => expect(mocks.overlay.mock.lastCall?.[0].activeTab).toBe("changes")); + expect(mocks.overlay.mock.lastCall?.[0].open).toBe(false); + + act(() => mocks.header.mock.lastCall?.[0].onOpenMobileMedia()); + + expect(mocks.overlay.mock.lastCall?.[0]).toMatchObject({ open: true, activeTab: "info" }); + expect(localStorage.getItem("open-inspect-session-inspector-tab")).toBe("changes"); +}); + it("keeps desktop actions available without collaboration and refreshes sidebar and overlay scope", () => { const { rerender } = render(); expect(mocks.composer).not.toHaveBeenCalled(); diff --git a/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx b/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx index a9e6b68ccc..c3893021f0 100644 --- a/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx @@ -267,6 +267,12 @@ function SessionContent({ const openMobileDetails = useCallback(() => { setIsDetailsOpen(true); }, []); + const openMobileMedia = useCallback(() => { + setIsDetailsOpen(true); + // Media lives in Info's Artifacts section. Showing it is navigation, so the + // viewer's remembered tab stays as it was. + showInspectorTab("info"); + }, [showInspectorTab]); const focusDetailsTrigger = useCallback( () => focusSessionDetailsTrigger(isPhone, actionsButtonRef.current, detailsButtonRef.current), [isPhone] @@ -451,6 +457,7 @@ function SessionContent({ onToggleDetails={toggleDetails} onToggleDesktopDetails={toggleDesktopDetails} onOpenMobileDetails={openMobileDetails} + onOpenMobileMedia={openMobileMedia} actions={{ sessionId, sessionStatus: sessionState?.status ?? DEFAULT_SESSION_STATUS, diff --git a/packages/web/src/app/(app)/(sidebar)/sessions/page.tsx b/packages/web/src/app/(app)/(sidebar)/sessions/page.tsx index fd8543225e..54296bd151 100644 --- a/packages/web/src/app/(app)/(sidebar)/sessions/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/sessions/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { isWorkspaceAdmin } from "@open-inspect/shared/rbac"; import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; @@ -76,8 +77,7 @@ function SessionsContent() { hasPermission, loading: authorizationLoading, } = useCurrentUserAuthorization(); - const canViewAllTeams = - authorization?.role.key === "owner" || authorization?.role.key === "administrator"; + const canViewAllTeams = isWorkspaceAdmin(authorization?.role.key); const canReadSessions = hasPermission("sessions.read"); const canCreateSession = hasPermission("sessions.create"); const { data: authSession } = useAuthSession(); diff --git a/packages/web/src/app/api/automations/[id]/route.test.ts b/packages/web/src/app/api/automations/[id]/route.test.ts index 38d557607e..57926e6fc1 100644 --- a/packages/web/src/app/api/automations/[id]/route.test.ts +++ b/packages/web/src/app/api/automations/[id]/route.test.ts @@ -6,7 +6,7 @@ vi.mock("@/lib/control-plane", () => ({ controlPlaneUserFetch: vi.fn() })); import { getServerAuthSession } from "@/lib/server-auth-session"; import { controlPlaneUserFetch } from "@/lib/control-plane"; -import { PUT } from "./route"; +import { PATCH, PUT } from "./route"; describe("automation update BFF", () => { beforeEach(() => { @@ -25,6 +25,7 @@ describe("automation update BFF", () => { providerSelections, providerAuth: [{ token: "secret" }], createdBy: "attacker", + teamId: "team-1", }), } as unknown as NextRequest, { params: Promise.resolve({ id: "auto-1" }) } @@ -33,4 +34,17 @@ describe("automation update BFF", () => { const body = JSON.parse(String(vi.mocked(controlPlaneUserFetch).mock.calls[0][1]?.body)); expect(body).toEqual({ name: "Updated", harness: "claude", providerSelections }); }); + + it("forwards executor changes separately from configuration", async () => { + await PATCH( + { + json: async () => ({ userId: "user-2", teamId: "team-1", name: "Injected" }), + } as unknown as NextRequest, + { params: Promise.resolve({ id: "auto-1" }) } + ); + expect(controlPlaneUserFetch).toHaveBeenCalledWith("/automations/auto-1", { + method: "PATCH", + body: JSON.stringify({ userId: "user-2" }), + }); + }); }); diff --git a/packages/web/src/app/api/automations/[id]/route.ts b/packages/web/src/app/api/automations/[id]/route.ts index 09b682313c..a2e471bb80 100644 --- a/packages/web/src/app/api/automations/[id]/route.ts +++ b/packages/web/src/app/api/automations/[id]/route.ts @@ -65,6 +65,25 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ } } +export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const session = await getServerAuthSession(); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { id } = await params; + try { + const body = await request.json(); + const response = await controlPlaneUserFetch(`/automations/${encodeURIComponent(id)}`, { + method: "PATCH", + body: JSON.stringify({ userId: body.userId }), + }); + return NextResponse.json(await response.json(), { status: response.status }); + } catch (error) { + console.error("Failed to update automation executor:", error); + return NextResponse.json({ error: "Failed to update automation executor" }, { status: 500 }); + } +} + export async function DELETE( _request: NextRequest, { params }: { params: Promise<{ id: string }> } diff --git a/packages/web/src/app/api/automations/route.test.ts b/packages/web/src/app/api/automations/route.test.ts index af594177d3..9ca95acb02 100644 --- a/packages/web/src/app/api/automations/route.test.ts +++ b/packages/web/src/app/api/automations/route.test.ts @@ -63,13 +63,14 @@ describe("automations API route (GET)", () => { cursor: "123:auto-1", repoOwner: "acme", repoName: "web-app", + teamId: "team/one", offset: "50", }) ); expect(response.status).toBe(200); expect(controlPlaneUserFetch).toHaveBeenCalledWith( - "/automations?search=daily+sync&limit=25&cursor=123%3Aauto-1&repoOwner=acme&repoName=web-app" + "/automations?search=daily+sync&limit=25&cursor=123%3Aauto-1&repoOwner=acme&repoName=web-app&teamId=team%2Fone" ); }); }); @@ -79,6 +80,13 @@ describe("automations API route (POST)", () => { vi.resetAllMocks(); }); + it.each(["team-1", null])("forwards create teamId=%s", async (teamId) => { + vi.mocked(getServerAuthSession).mockResolvedValue({ user: { id: "user-1" } }); + vi.mocked(controlPlaneUserFetch).mockResolvedValue(Response.json({ automation: {} })); + await POST(postRequest({ ...validBody, teamId })); + expect(controlPlaneBody()).toEqual({ ...validBody, teamId }); + }); + it("returns 401 when the user session is missing", async () => { vi.mocked(getServerAuthSession).mockResolvedValue(null); diff --git a/packages/web/src/app/api/automations/route.ts b/packages/web/src/app/api/automations/route.ts index d2bd0774ce..0ad43d1563 100644 --- a/packages/web/src/app/api/automations/route.ts +++ b/packages/web/src/app/api/automations/route.ts @@ -10,6 +10,7 @@ const AUTOMATION_LIST_QUERY_PARAMS = [ "cursor", "repoOwner", "repoName", + "teamId", ] as const; export async function GET(request: NextRequest) { @@ -61,6 +62,7 @@ export async function POST(request: NextRequest) { repositories: body.repositories, environmentIds: body.environmentIds, providerSelections: body.providerSelections, + teamId: body.teamId, }; const response = await controlPlaneUserFetch("/automations", { diff --git a/packages/web/src/app/api/environments/[id]/route.test.ts b/packages/web/src/app/api/environments/[id]/route.test.ts new file mode 100644 index 0000000000..b48251406c --- /dev/null +++ b/packages/web/src/app/api/environments/[id]/route.test.ts @@ -0,0 +1,47 @@ +import { NextRequest } from "next/server"; +import { beforeEach, expect, it, vi } from "vitest"; +import { getServerAuthSession } from "@/lib/server-auth-session"; +import { controlPlaneUserFetch } from "@/lib/control-plane"; +import { PUT } from "./route"; + +vi.mock("@/lib/server-auth-session", () => ({ getServerAuthSession: vi.fn() })); +vi.mock("@/lib/control-plane", () => ({ controlPlaneUserFetch: vi.fn() })); + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getServerAuthSession).mockResolvedValue({ user: { id: "user-1" } }); + vi.mocked(controlPlaneUserFetch).mockResolvedValue(Response.json({ environment: {} })); +}); + +it("omits ownership and unknown fields from configuration updates", async () => { + await PUT( + new NextRequest("http://localhost/api/environments/env-1", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "Stack", + teamId: "team-2", + ownerTeamId: "team-2", + userId: "injected", + }), + }), + { params: Promise.resolve({ id: "env-1" }) } + ); + expect(controlPlaneUserFetch).toHaveBeenCalledWith("/environments/env-1", { + method: "PUT", + body: JSON.stringify({ name: "Stack" }), + }); +}); + +it.each([null, [], "name"])("rejects a non-object update body %j", async (body) => { + const response = await PUT( + new NextRequest("http://localhost/api/environments/env-1", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ id: "env-1" }) } + ); + expect(response.status).toBe(400); + expect(controlPlaneUserFetch).not.toHaveBeenCalled(); +}); diff --git a/packages/web/src/app/api/environments/[id]/route.ts b/packages/web/src/app/api/environments/[id]/route.ts index 8a0ba0df61..7261bb1981 100644 --- a/packages/web/src/app/api/environments/[id]/route.ts +++ b/packages/web/src/app/api/environments/[id]/route.ts @@ -2,6 +2,9 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getServerAuthSession } from "@/lib/server-auth-session"; import { controlPlaneUserFetch } from "@/lib/control-plane"; +import { updateEnvironmentInputSchema } from "@open-inspect/shared/types/environments"; + +const UPDATE_FIELDS = Object.keys(updateEnvironmentInputSchema.shape); export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const session = await getServerAuthSession(); @@ -31,10 +34,20 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ try { const body = await request.json(); + if (typeof body !== "object" || body === null || Array.isArray(body)) { + return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); + } + // Forward only configuration fields; ownership is fixed at creation. + const environmentBody = Object.fromEntries( + UPDATE_FIELDS.filter((field) => body[field] !== undefined).map((field) => [ + field, + body[field], + ]) + ); const response = await controlPlaneUserFetch(`/environments/${encodeURIComponent(id)}`, { method: "PUT", - body: JSON.stringify(body), + body: JSON.stringify(environmentBody), }); const data = await response.json(); diff --git a/packages/web/src/app/api/environments/route.test.ts b/packages/web/src/app/api/environments/route.test.ts index ff3bc1fd9a..0a872da8b9 100644 --- a/packages/web/src/app/api/environments/route.test.ts +++ b/packages/web/src/app/api/environments/route.test.ts @@ -2,12 +2,12 @@ import { NextRequest } from "next/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { getServerAuthSession } from "@/lib/server-auth-session"; import { controlPlaneUserFetch } from "@/lib/control-plane"; -import { GET } from "./route"; +import { GET, POST } from "./route"; vi.mock("@/lib/server-auth-session", () => ({ getServerAuthSession: vi.fn() })); vi.mock("@/lib/control-plane", () => ({ controlPlaneUserFetch: vi.fn() })); -describe("environments list proxy", () => { +describe("environments API proxy", () => { beforeEach(() => { vi.resetAllMocks(); vi.mocked(getServerAuthSession).mockResolvedValue({ user: { id: "user-1" } }); @@ -21,11 +21,16 @@ describe("environments list proxy", () => { expect(controlPlaneUserFetch).toHaveBeenCalledWith("/environments?teamId=team%2Fone"); }); - it("keeps workspace requests unscoped", async () => { + it("keeps omitted team filters unscoped", async () => { await GET(new NextRequest("http://localhost/api/environments")); expect(controlPlaneUserFetch).toHaveBeenCalledWith("/environments"); }); + it("forwards explicit workspace ownership", async () => { + await GET(new NextRequest("http://localhost/api/environments?teamId=null")); + expect(controlPlaneUserFetch).toHaveBeenCalledWith("/environments?teamId=null"); + }); + it("preserves list denials", async () => { vi.mocked(controlPlaneUserFetch).mockResolvedValue( Response.json({ error: "Forbidden" }, { status: 403 }) @@ -34,4 +39,19 @@ describe("environments list proxy", () => { expect(response.status).toBe(403); await expect(response.json()).resolves.toEqual({ error: "Forbidden" }); }); + + it.each(["team-1", null])("forwards creation teamId=%s", async (teamId) => { + const body = { name: "Stack", teamId, repositories: [{ repoOwner: "acme", repoName: "app" }] }; + await POST( + new NextRequest("http://localhost/api/environments", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + ); + expect(controlPlaneUserFetch).toHaveBeenCalledWith("/environments", { + method: "POST", + body: JSON.stringify(body), + }); + }); }); diff --git a/packages/web/src/app/api/environments/route.ts b/packages/web/src/app/api/environments/route.ts index 779489955c..b7128d0796 100644 --- a/packages/web/src/app/api/environments/route.ts +++ b/packages/web/src/app/api/environments/route.ts @@ -12,7 +12,10 @@ export async function GET(request: NextRequest) { try { const response = await controlPlaneUserFetch( - buildControlPlanePath("/environments", request.nextUrl.searchParams, ["teamId"]) + buildControlPlanePath("/environments", request.nextUrl.searchParams, [ + "teamId", + "ownerTeamId", + ]) ); const data = await response.json(); return NextResponse.json(data, { status: response.status }); diff --git a/packages/web/src/app/api/repos/route.test.ts b/packages/web/src/app/api/repos/route.test.ts index 7361f8a060..f36a0da744 100644 --- a/packages/web/src/app/api/repos/route.test.ts +++ b/packages/web/src/app/api/repos/route.test.ts @@ -11,7 +11,9 @@ describe("repositories list proxy", () => { beforeEach(() => { vi.resetAllMocks(); vi.mocked(getServerAuthSession).mockResolvedValue({ user: { id: "user-1" } }); - vi.mocked(controlPlaneUserFetch).mockResolvedValue(Response.json({ repos: [] })); + vi.mocked(controlPlaneUserFetch).mockResolvedValue( + Response.json({ repos: [], cached: false, cachedAt: "2026-10-01T00:00:00Z" }) + ); }); it("forwards only teamId", async () => { @@ -26,6 +28,27 @@ describe("repositories list proxy", () => { expect(controlPlaneUserFetch).toHaveBeenCalledWith("/repos"); }); + it.each([true, false])( + "preserves authoritative team grant metadata (%s)", + async (teamHasRepositoryGrants) => { + vi.mocked(controlPlaneUserFetch).mockResolvedValue( + Response.json({ + repos: [], + cached: false, + cachedAt: "2026-10-01T00:00:00Z", + teamHasRepositoryGrants, + }) + ); + const response = await GET(new NextRequest("http://localhost/api/repos?teamId=team-1")); + await expect(response.json()).resolves.toEqual({ repos: [], teamHasRepositoryGrants }); + } + ); + + it("does not invent grant metadata for the installation catalog", async () => { + const response = await GET(new NextRequest("http://localhost/api/repos")); + await expect(response.json()).resolves.toEqual({ repos: [] }); + }); + it("does not fetch without authentication", async () => { vi.mocked(getServerAuthSession).mockResolvedValue(null); expect((await GET(new NextRequest("http://localhost/api/repos?teamId=team-1"))).status).toBe( diff --git a/packages/web/src/app/api/repos/route.ts b/packages/web/src/app/api/repos/route.ts index d186dfd938..23d382b07c 100644 --- a/packages/web/src/app/api/repos/route.ts +++ b/packages/web/src/app/api/repos/route.ts @@ -30,7 +30,10 @@ export async function GET(request: NextRequest) { if (!parsed.success) throw new Error("Invalid control plane repositories response"); // The control plane returns repos in the format we need - return NextResponse.json({ repos: parsed.data.repos }); + return NextResponse.json({ + repos: parsed.data.repos, + teamHasRepositoryGrants: parsed.data.teamHasRepositoryGrants, + }); } catch (error) { console.error("Error fetching repos:", error); return NextResponse.json({ error: "Internal server error" }, { status: 500 }); diff --git a/packages/web/src/app/api/teams/[id]/repository-grants/[grantId]/route.test.ts b/packages/web/src/app/api/teams/[id]/repository-grants/[grantId]/route.test.ts new file mode 100644 index 0000000000..2c848b9f60 --- /dev/null +++ b/packages/web/src/app/api/teams/[id]/repository-grants/[grantId]/route.test.ts @@ -0,0 +1,41 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { controlPlaneUserFetch } from "@/lib/control-plane"; +import { DELETE } from "./route"; + +vi.mock("@/lib/control-plane", () => ({ controlPlaneUserFetch: vi.fn() })); +beforeEach(() => vi.resetAllMocks()); + +describe("delete team repository grant proxy", () => { + it("encodes both IDs and preserves a bodyless 204", async () => { + vi.mocked(controlPlaneUserFetch).mockResolvedValue(new Response(null, { status: 204 })); + const request = new NextRequest( + "http://localhost/api/teams/one/repository-grants/grant?teamId=other", + { method: "DELETE" } + ); + const response = await DELETE(request, { + params: Promise.resolve({ id: "team/one", grantId: "grant/one" }), + }); + expect(controlPlaneUserFetch).toHaveBeenCalledWith( + "/teams/team%2Fone/repository-grants/grant%2Fone", + { method: "DELETE" } + ); + expect(response.status).toBe(204); + expect(await response.text()).toBe(""); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + }); + + it.each([401, 403, 404, 409])("preserves an upstream %s denial", async (status) => { + vi.mocked(controlPlaneUserFetch).mockResolvedValue( + Response.json({ error: "Denied" }, { status }) + ); + const response = await DELETE( + new NextRequest("http://localhost/api/teams/one/repository-grants/grant", { + method: "DELETE", + }), + { params: Promise.resolve({ id: "team-1", grantId: "grant-1" }) } + ); + expect(response.status).toBe(status); + await expect(response.json()).resolves.toEqual({ error: "Denied" }); + }); +}); diff --git a/packages/web/src/app/api/teams/[id]/repository-grants/[grantId]/route.ts b/packages/web/src/app/api/teams/[id]/repository-grants/[grantId]/route.ts new file mode 100644 index 0000000000..898e3c5e99 --- /dev/null +++ b/packages/web/src/app/api/teams/[id]/repository-grants/[grantId]/route.ts @@ -0,0 +1,7 @@ +import { settingsProxy } from "@/lib/settings-proxy"; + +export const { DELETE } = settingsProxy( + ({ id, grantId }: { id: string; grantId: string }) => + `/teams/${encodeURIComponent(id)}/repository-grants/${encodeURIComponent(grantId)}`, + "team repository grant" +); diff --git a/packages/web/src/app/api/teams/[id]/repository-grants/route.test.ts b/packages/web/src/app/api/teams/[id]/repository-grants/route.test.ts new file mode 100644 index 0000000000..7f4298a366 --- /dev/null +++ b/packages/web/src/app/api/teams/[id]/repository-grants/route.test.ts @@ -0,0 +1,81 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { controlPlaneUserFetch } from "@/lib/control-plane"; +import { GET, PUT } from "./route"; + +vi.mock("@/lib/control-plane", () => ({ controlPlaneUserFetch: vi.fn() })); +beforeEach(() => vi.resetAllMocks()); + +const context = { params: Promise.resolve({ id: "team/one" }) }; +const grant = { + id: "grant-1", + teamId: "team/one", + kind: "installation", + repoExternalId: null, + owner: null, + name: null, + createdAt: 1, +}; + +describe("team repository grants proxy", () => { + it("encodes team IDs, strips arbitrary filters, and relays the grants list", async () => { + vi.mocked(controlPlaneUserFetch).mockResolvedValue(Response.json({ grants: [grant] })); + const response = await GET( + new NextRequest("http://localhost/api/teams/one/repository-grants?teamId=other"), + context + ); + expect(controlPlaneUserFetch).toHaveBeenCalledWith( + "/teams/team%2Fone/repository-grants", + undefined + ); + await expect(response.json()).resolves.toEqual({ grants: [grant] }); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + }); + + it.each([ + { kind: "installation" }, + { kind: "repository", repoExternalId: 42, owner: "group/subgroup", name: "api" }, + ])("forwards a PUT grant unchanged (%j)", async (body) => { + vi.mocked(controlPlaneUserFetch).mockResolvedValue(Response.json({ grant }, { status: 201 })); + const response = await PUT( + new NextRequest("http://localhost/api/teams/one/repository-grants", { + method: "PUT", + headers: { Cookie: "__Secure-openinspect.session_token=session.signature" }, + body: JSON.stringify(body), + }), + context + ); + expect(controlPlaneUserFetch).toHaveBeenCalledWith("/teams/team%2Fone/repository-grants", { + method: "PUT", + body: JSON.stringify(body), + }); + expect(response.status).toBe(201); + await expect(response.json()).resolves.toEqual({ grant }); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + }); + + it.each([401, 403, 404, 409])( + "preserves upstream denials and conflict codes (%s)", + async (status) => { + const failure = { error: "Denied", code: "grant_conflict" }; + vi.mocked(controlPlaneUserFetch).mockResolvedValue(Response.json(failure, { status })); + const response = await GET( + new NextRequest("http://localhost/api/teams/one/repository-grants"), + context + ); + expect(response.status).toBe(status); + await expect(response.json()).resolves.toEqual(failure); + } + ); + + it("does not read or forward an unauthenticated mutation", async () => { + const request = new NextRequest("http://localhost/api/teams/one/repository-grants", { + method: "PUT", + body: "{malformed", + }); + const response = await PUT(request, context); + expect(response.status).toBe(401); + expect(request.bodyUsed).toBe(false); + expect(controlPlaneUserFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/app/api/teams/[id]/repository-grants/route.ts b/packages/web/src/app/api/teams/[id]/repository-grants/route.ts new file mode 100644 index 0000000000..20c66bd959 --- /dev/null +++ b/packages/web/src/app/api/teams/[id]/repository-grants/route.ts @@ -0,0 +1,6 @@ +import { settingsProxy } from "@/lib/settings-proxy"; + +export const { GET, PUT } = settingsProxy( + ({ id }: { id: string }) => `/teams/${encodeURIComponent(id)}/repository-grants`, + "team repository grants" +); diff --git a/packages/web/src/app/providers.tsx b/packages/web/src/app/providers.tsx index b5e23e4d96..cd4907a752 100644 --- a/packages/web/src/app/providers.tsx +++ b/packages/web/src/app/providers.tsx @@ -5,13 +5,14 @@ import { SWRConfig } from "swr"; import { Toaster } from "@/components/ui/sonner"; import { SyntaxHighlightTheme } from "@/components/syntax-highlight-theme"; import { browserApiFetch, type BrowserApiPath } from "@/lib/browser-api-fetch"; +import { SwrFetchError } from "@/lib/swr-fetch-error"; async function swrFetcher(url: BrowserApiPath): Promise { // SWR falls back to this fetcher for every hook that omits its own, including // hooks whose key is local state rather than a request path. if (!url.startsWith("/api/")) throw new Error(`SWR key is not a BFF API path: ${url}`); const res = await browserApiFetch(url); - if (!res.ok) throw new Error(`Fetch failed: ${res.status}`); + if (!res.ok) throw new SwrFetchError(res.status); return res.json(); } diff --git a/packages/web/src/components/automations/automation-collection.test.tsx b/packages/web/src/components/automations/automation-collection.test.tsx new file mode 100644 index 0000000000..003a03dd31 --- /dev/null +++ b/packages/web/src/components/automations/automation-collection.test.tsx @@ -0,0 +1,179 @@ +// @vitest-environment jsdom +/// + +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + DEFAULT_AUTOMATION_MAX_CONCURRENT_RUNS, + type AutomationListItem, +} from "@open-inspect/shared/types/automations"; +import { TeamAutomations } from "@/components/teams/team-automations"; +import { AutomationCollection } from "./automation-collection"; +import { browserApiFetch } from "@/lib/browser-api-fetch"; + +expect.extend(matchers); +afterEach(cleanup); + +const mocks = vi.hoisted(() => ({ + useAutomations: vi.fn(), + canCreate: true, + membership: { + teams: [{ id: "team/one" }], + loading: false, + error: undefined as Error | undefined, + }, + invalidate: vi.fn(), +})); +vi.mock("@/hooks/use-automations", () => ({ useAutomations: mocks.useAutomations })); +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ hasPermission: () => mocks.canCreate }), +})); +vi.mock("@/hooks/use-teams", () => ({ useMeTeams: () => mocks.membership })); +vi.mock("@/hooks/use-environments", () => ({ useEnvironments: () => ({ environments: [] }) })); +vi.mock("@/lib/browser-api-fetch", () => ({ browserApiFetch: vi.fn() })); +vi.mock("@/lib/automation-cache", () => ({ invalidateAutomationCache: mocks.invalidate })); +vi.mock("swr", () => ({ useSWRConfig: () => mocks })); +vi.mock("next/link", () => ({ + default: ({ children, ...props }: React.ComponentProps<"a">) => {children}, +})); + +const automation: AutomationListItem = { + id: "auto-1", + name: "Nightly review", + instructions: "Review", + harness: "opencode", + triggerType: "schedule", + scheduleCron: "0 9 * * *", + scheduleTz: "UTC", + model: "openai/gpt-5.4", + reasoningEffort: null, + enabled: true, + maxConcurrentRuns: DEFAULT_AUTOMATION_MAX_CONCURRENT_RUNS, + nextRunAt: null, + consecutiveFailures: 0, + createdBy: "user-1", + userId: null, + ownerTeamId: null, + createdAt: 1, + updatedAt: 1, + deletedAt: null, + eventType: null, + triggerConfig: null, + repositories: [], + environmentIds: [], + providerSelections: {}, + recentExecutions: [], + capabilities: { canRead: true, canManage: true, canTrigger: true }, +}; +const list = { + automations: [automation], + loading: false, + loadingMore: false, + error: undefined as Error | undefined, + hasMore: false, + loadMore: vi.fn(), + mutate: vi.fn(), +}; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.canCreate = true; + mocks.membership = { teams: [{ id: "team/one" }], loading: false, error: undefined }; + mocks.useAutomations.mockReturnValue(list); + mocks.invalidate.mockResolvedValue(undefined); + vi.mocked(browserApiFetch).mockResolvedValue(Response.json({})); +}); + +describe("automation collection", () => { + it("scopes the team tab's request and row navigation", () => { + render(); + expect(screen.getByRole("link", { name: "Nightly review" })).toHaveAttribute( + "href", + "/automations/auto-1?teamId=team%2Fone" + ); + expect(mocks.useAutomations).toHaveBeenCalledWith("", "team/one"); + }); + + it("does not show an empty-state creation prompt after a failed initial load", () => { + mocks.useAutomations.mockReturnValue({ ...list, automations: [], error: new Error("failed") }); + render(); + expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); + expect(screen.queryByText("No automations yet.")).not.toBeInTheDocument(); + }); + + it.each(["permission", "nonmember", "loading", "error"])( + "gates both team creation entry points on %s", + (failure) => { + mocks.useAutomations.mockReturnValue({ ...list, automations: [] }); + if (failure === "permission") mocks.canCreate = false; + if (failure === "nonmember") mocks.membership.teams = []; + if (failure === "loading") mocks.membership.loading = true; + if (failure === "error") mocks.membership.error = new Error("failed"); + render(); + expect(screen.queryByRole("link", { name: "Create Automation" })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Start from a template" })).not.toBeInTheDocument(); + } + ); + + it("does not require team membership for the unscoped collection", () => { + mocks.membership = { teams: [], loading: true, error: new Error("failed") }; + mocks.useAutomations.mockReturnValue({ ...list, automations: [] }); + render({() => null}); + expect(screen.getByRole("link", { name: "Create Automation" })).toHaveAttribute( + "href", + "/automations/new" + ); + }); + + it("keeps team creation and template links scoped in the empty state", () => { + mocks.useAutomations.mockReturnValue({ ...list, automations: [] }); + render(); + for (const link of screen.getAllByRole("link", { name: "Create Automation" })) { + expect(link).toHaveAttribute("href", "/automations/new?teamId=team%2Fone"); + } + expect(screen.getByRole("link", { name: "Start from a template" })).toHaveAttribute( + "href", + "/automations/templates?teamId=team%2Fone" + ); + }); + + it.each([ + ["pause", "Pause", "/pause", "POST"], + ["resume", "Resume", "/resume", "POST"], + ["trigger", "Trigger", "/trigger", "POST"], + ["delete", "Delete", "", "DELETE"], + ])("dispatches %s and invalidates its resource", async (action, label, suffix, method) => { + mocks.useAutomations.mockReturnValue({ + ...list, + automations: [{ ...automation, enabled: action !== "resume" }], + }); + render(); + fireEvent.click(screen.getByRole("button", { name: label })); + if (action === "delete") { + expect(browserApiFetch).not.toHaveBeenCalled(); + fireEvent.click( + within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete" }) + ); + } + await waitFor(() => + expect(mocks.invalidate).toHaveBeenCalledWith(mocks, "auto-1", { + deleted: action === "delete", + }) + ); + expect(browserApiFetch).toHaveBeenCalledWith(`/api/automations/auto-1${suffix}`, { method }); + }); + + it("reports action failure and clears it on a successful retry", async () => { + vi.mocked(browserApiFetch).mockResolvedValueOnce(Response.json({}, { status: 403 })); + render(); + fireEvent.click(screen.getByRole("button", { name: "Trigger" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Failed to trigger automation"); + expect(mocks.invalidate).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Trigger" })); + await waitFor(() => + expect(mocks.invalidate).toHaveBeenCalledWith(mocks, "auto-1", { deleted: false }) + ); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/components/automations/automation-collection.tsx b/packages/web/src/components/automations/automation-collection.tsx new file mode 100644 index 0000000000..06a4178b52 --- /dev/null +++ b/packages/web/src/components/automations/automation-collection.tsx @@ -0,0 +1,77 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useAutomations } from "@/hooks/use-automations"; +import { useAutomationActions } from "@/hooks/use-automation-actions"; +import { useCanCreateAutomation } from "@/hooks/use-can-create-automation"; +import { AutomationsList } from "./automations-list"; +import { Button } from "@/components/ui/button"; +import { ErrorBanner } from "@/components/ui/error-banner"; + +export function AutomationCollection({ + teamId, + nameSearch = "", + children, +}: { + teamId?: string; + nameSearch?: string; + children: (canCreate: boolean) => ReactNode; +}) { + const { automations, loading, loadingMore, error, hasMore, loadMore, mutate } = useAutomations( + nameSearch, + teamId + ); + const { canCreate } = useCanCreateAutomation(teamId); + const { act, actionError } = useAutomationActions(); + + return ( + <> + {children(canCreate)} + {actionError && ( + + {actionError} + + )} + {error && ( + +
+ Failed to load automations. + +
+
+ )} + {loading ? ( +
+
+
+ ) : automations.length > 0 || !error ? ( + void act(id, "pause")} + onResume={(id) => void act(id, "resume")} + onTrigger={(id) => void act(id, "trigger")} + onDelete={(id) => void act(id, "delete")} + /> + ) : null} + {(hasMore || loadingMore) && !loading && ( +
+ +
+ )} + + ); +} diff --git a/packages/web/src/components/automations/automation-form-policy.ts b/packages/web/src/components/automations/automation-form-policy.ts index ebe9978252..57bd99b076 100644 --- a/packages/web/src/components/automations/automation-form-policy.ts +++ b/packages/web/src/components/automations/automation-form-policy.ts @@ -24,6 +24,7 @@ import type { HarnessModelAvailability } from "@/lib/session-harness"; import type { ModelProviderSelections } from "@open-inspect/shared/types/provider-accounts"; export interface AutomationFormValues { + teamId?: string | null; name: string; repositories: AutomationRepositoryInput[]; environmentIds: string[]; diff --git a/packages/web/src/components/automations/automation-form.test.tsx b/packages/web/src/components/automations/automation-form.test.tsx index 80fae92032..c4f4b83df7 100644 --- a/packages/web/src/components/automations/automation-form.test.tsx +++ b/packages/web/src/components/automations/automation-form.test.tsx @@ -1,8 +1,9 @@ // @vitest-environment jsdom /// -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import * as matchers from "@testing-library/jest-dom/matchers"; import type { ReactNode } from "react"; import { MAX_AUTOMATION_REPOSITORIES } from "@open-inspect/shared/types/automations"; @@ -45,8 +46,27 @@ let environmentsValue: Array<{ id: string; name: string; repositories: Array<{ repoOwner: string; repoName: string }>; + capabilities?: { canUse: boolean }; }> = []; +const scopeMocks = vi.hoisted(() => ({ + repos: vi.fn(), + environments: vi.fn(), + allowWorkspace: true, +})); +vi.mock("@/hooks/use-resource-teams", () => ({ + useResourceTeams: () => ({ + teams: [ + { id: "team-1", name: "Engineering" }, + { id: "team-2", name: "Design" }, + ], + allTeams: [], + loading: false, + error: null, + allowWorkspace: scopeMocks.allowWorkspace, + }), +})); beforeEach(() => { + scopeMocks.allowWorkspace = true; enabledModelsValue = ["openai/gpt-5.4"]; loadingModelsValue = false; environmentsValue = []; @@ -58,17 +78,17 @@ beforeEach(() => { }); vi.mock("@/hooks/use-repos", () => ({ - useRepos: () => ({ - repos: reposValue, - loading: false, - }), + useRepos: (enabled: boolean, teamId: string | null) => { + scopeMocks.repos(enabled, teamId); + return { repos: reposValue, loading: false }; + }, })); vi.mock("@/hooks/use-environments", () => ({ - useEnvironments: () => ({ - environments: environmentsValue, - loading: false, - }), + useEnvironments: (teamId: string | null) => { + scopeMocks.environments(teamId); + return { environments: environmentsValue, loading: false }; + }, })); vi.mock("@/hooks/use-branches", () => ({ @@ -113,7 +133,94 @@ const singleRepository = [ const openRepositoryPicker = () => fireEvent.click(screen.getByRole("button", { name: "Repository Configuration" })); +beforeAll(() => { + // Radix Select uses pointer capture, which jsdom lacks. + Element.prototype.hasPointerCapture = () => false; + Element.prototype.releasePointerCapture = () => {}; + Element.prototype.scrollIntoView = vi.fn(); +}); + +async function chooseTeam(user: ReturnType, name: string) { + await user.click(screen.getByRole("combobox", { name: "Team" })); + await user.click(await screen.findByRole("option", { name })); +} + describe("automation cron submission", () => { + it("scopes creation targets and clears selections when the team changes", async () => { + const user = userEvent.setup(); + environmentsValue = [ + { + id: "env-draft", + name: "Draft environment", + repositories: [], + capabilities: { canUse: true }, + }, + ]; + const onSubmit = vi.fn(); + const { container } = render( + + ); + expect(scopeMocks.repos).toHaveBeenLastCalledWith(true, "team-1"); + expect(scopeMocks.environments).toHaveBeenLastCalledWith({ ownerTeamId: "team-1" }); + openRepositoryPicker(); + fireEvent.click(screen.getByRole("button", { name: "Select Multiple" })); + fireEvent.click(screen.getByRole("checkbox", { name: /Draft environment/ })); + await chooseTeam(user, "Design"); + expect(scopeMocks.repos).toHaveBeenLastCalledWith(true, "team-2"); + expect(scopeMocks.environments).toHaveBeenLastCalledWith({ ownerTeamId: "team-2" }); + fireEvent.submit(container.querySelector("form")!); + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ teamId: "team-2", repositories: [], environmentIds: [] }) + ); + scopeMocks.allowWorkspace = false; + await chooseTeam(user, "Workspace (no team)"); + expect(screen.getByRole("button", { name: "Create Automation" })).toBeDisabled(); + fireEvent.submit(container.querySelector("form")!); + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it.each(["team-1", null])("keeps owner %s read-only and omits edit ownership", (teamId) => { + scopeMocks.allowWorkspace = false; + const onSubmit = vi.fn(); + const { container } = render( + + ); + expect(screen.getByRole("combobox", { name: "Team" })).toBeDisabled(); + expect(screen.getByRole("combobox", { name: "Team" })).toHaveTextContent( + teamId ? "Engineering" : "Workspace (no team)" + ); + fireEvent.submit(container.querySelector("form")!); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0]).not.toHaveProperty("teamId"); + expect(onSubmit.mock.calls[0][0].repositories).toEqual(singleRepository); + }); + + it("requests workspace-owned environments for a workspace automation", () => { + render(); + expect(scopeMocks.environments).toHaveBeenLastCalledWith({ ownerTeamId: null }); + }); + it("locks provider authentication while submitting", () => { const props = { mode: "create" as const, @@ -514,12 +621,64 @@ describe("environment binding", () => { const fullstackEnvironment = { id: "env_1", name: "Fullstack", + capabilities: { canUse: true }, repositories: [ { repoOwner: "acme", repoName: "web-app" }, { repoOwner: "acme", repoName: "api" }, ], }; + it.each([undefined, false])( + "permits unchanged and cleared edits but blocks changed targets without canUse %s", + (canUse) => { + const capabilities = canUse === undefined ? undefined : { canUse }; + environmentsValue = [ + { ...fullstackEnvironment, capabilities }, + { ...fullstackEnvironment, id: "env_2", name: "Data", capabilities }, + ]; + const onSubmit = vi.fn(); + const { container } = render( + + ); + openRepositoryPicker(); + expect(screen.getByRole("checkbox", { name: /Fullstack/ })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Save Changes" })).toBeEnabled(); + fireEvent.submit(container.querySelector("form")!); + expect(onSubmit).toHaveBeenLastCalledWith( + expect.objectContaining({ environmentIds: ["env_1", "env_2"] }) + ); + fireEvent.click(screen.getByRole("button", { name: "Select One" })); + expect(screen.getByRole("button", { name: "Save Changes" })).toBeDisabled(); + fireEvent.submit(container.querySelector("form")!); + expect(onSubmit).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole("button", { name: "No repository" })); + expect(screen.getByRole("button", { name: "Save Changes" })).toBeEnabled(); + fireEvent.submit(container.querySelector("form")!); + expect(onSubmit).toHaveBeenLastCalledWith(expect.objectContaining({ environmentIds: [] })); + } + ); + + it("does not exempt prefilled creation targets from use permission", () => { + environmentsValue = [{ ...fullstackEnvironment, capabilities: { canUse: false } }]; + const onSubmit = vi.fn(); + const { container } = render( + + ); + expect(screen.getByRole("button", { name: "Create Automation" })).toBeDisabled(); + fireEvent.submit(container.querySelector("form")!); + expect(onSubmit).not.toHaveBeenCalled(); + }); + it("submits the selected environment in single-select mode", () => { environmentsValue = [fullstackEnvironment]; const onSubmit = vi.fn(); @@ -677,7 +836,12 @@ describe("environment binding", () => { it("preserves hydrated multi-environment selections on untouched edits", () => { environmentsValue = [ fullstackEnvironment, - { id: "env_2", name: "Data", repositories: [{ repoOwner: "acme", repoName: "data" }] }, + { + id: "env_2", + name: "Data", + repositories: [{ repoOwner: "acme", repoName: "data" }], + capabilities: { canUse: true }, + }, ]; const onSubmit = vi.fn(); const { container } = render( diff --git a/packages/web/src/components/automations/automation-form.tsx b/packages/web/src/components/automations/automation-form.tsx index a240f7cc3b..0b8632fbd2 100644 --- a/packages/web/src/components/automations/automation-form.tsx +++ b/packages/web/src/components/automations/automation-form.tsx @@ -3,6 +3,8 @@ import { useCallback, useState, useMemo } from "react"; import { useRepos } from "@/hooks/use-repos"; import { useEnvironments } from "@/hooks/use-environments"; +import { useResourceTeams } from "@/hooks/use-resource-teams"; +import { ResourceTeamField } from "@/components/resource-team-field"; import { useEnabledModels } from "@/hooks/use-enabled-models"; import { reconcileProviderSelectionsForHarness } from "@open-inspect/shared/harnesses"; import { resolveHarnessModelSelection } from "@/lib/session-harness"; @@ -14,6 +16,7 @@ import { FieldDescription } from "./automation-form-field"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useAutomationTargets } from "./use-automation-targets"; +import { sameEnvironmentIds } from "./automation-target-selection"; import { AutomationAgentFields } from "./automation-agent-fields"; import { AutomationTargetPicker } from "./automation-target-picker"; import { AutomationInstructionsField } from "./automation-instructions-field"; @@ -39,8 +42,15 @@ interface AutomationFormProps { } export function AutomationForm({ mode, initialValues, onSubmit, submitting }: AutomationFormProps) { - const { repos, loading: loadingRepos } = useRepos(); - const { environments, loading: loadingEnvironments } = useEnvironments(); + const [teamId, setTeamId] = useState(initialValues?.teamId ?? null); + const scope = useResourceTeams("automation"); + const scopeValid = + mode === "edit" || + (!scope.loading && + !scope.error && + (teamId ? scope.teams.some((team) => team.id === teamId) : scope.allowWorkspace)); + const { repos, loading: loadingRepos } = useRepos(true, teamId); + const { environments, loading: loadingEnvironments } = useEnvironments({ ownerTeamId: teamId }); const { enabledModels, enabledModelOptions, loading: loadingModels } = useEnabledModels(); const providerAccounts = useProviderAccounts(); const initialDraft = useMemo(() => createAutomationFormDraft(initialValues), [initialValues]); @@ -69,6 +79,16 @@ export function AutomationForm({ mode, initialValues, onSubmit, submitting }: Au repos, }); const { selectedEnvironmentIds, buildRepositoriesPayload } = targets; + const environmentsUsable = + (mode === "edit" && + sameEnvironmentIds(selectedEnvironmentIds, initialValues?.environmentIds ?? [])) || + selectedEnvironmentIds.length === 0 || + (!loadingEnvironments && + selectedEnvironmentIds.every((id) => + environments.some( + (environment) => environment.id === id && environment.capabilities?.canUse === true + ) + )); // The model we display and submit, and whether it may be submitted. The // selector only lists enabled models the harness can run, so a disabled @@ -134,12 +154,23 @@ export function AutomationForm({ mode, initialValues, onSubmit, submitting }: Au const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (!formEvaluation.valid) return; - onSubmit(formEvaluation.values); + if (!formEvaluation.valid || !scopeValid || !environmentsUsable || submitting) return; + onSubmit({ ...formEvaluation.values, ...(mode === "create" ? { teamId } : {}) }); }; return (
+ { + if (submitting || mode === "edit") return; + setTeamId(nextTeamId); + targets.resetTargets(); + }} + /> {/* Name */} @@ -223,7 +254,10 @@ export function AutomationForm({ mode, initialValues, onSubmit, submitting }: Au {/* Submit */}
-
)} @@ -138,15 +138,14 @@ export function AutomationsList({ <>
{automations.map((automation) => { - const canManage = canAccessAutomation("automations.manage", authorization, automation); - const canTrigger = canAccessAutomation("automations.trigger", authorization, automation); + const { canManage, canTrigger } = automation.capabilities; return (
{/* Header: Name + badge | Actions */}
{automation.name} diff --git a/packages/web/src/components/automations/template-gallery.tsx b/packages/web/src/components/automations/template-gallery.tsx index 0752d15d27..b10b77e60a 100644 --- a/packages/web/src/components/automations/template-gallery.tsx +++ b/packages/web/src/components/automations/template-gallery.tsx @@ -10,6 +10,7 @@ import { type TemplateCategory, } from "@/lib/automation-templates"; import { Button } from "@/components/ui/button"; +import { automationNavigation } from "@/lib/automation-navigation"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ClockIcon, @@ -56,7 +57,7 @@ function OutputIcon({ output }: { output: AutomationTemplate["primaryOutput"] }) ); } -function TemplateCard({ template }: { template: AutomationTemplate }) { +function TemplateCard({ template, teamId }: { template: AutomationTemplate; teamId?: string }) { const triggerLabel = TRIGGER_LABELS[template.prefill.triggerType] ?? "Schedule"; const outputLabel = OUTPUT_LABELS[template.primaryOutput]; @@ -92,7 +93,7 @@ function TemplateCard({ template }: { template: AutomationTemplate }) {
diff --git a/packages/web/src/components/automations/use-automation-targets.ts b/packages/web/src/components/automations/use-automation-targets.ts index df90e53ad2..537699f0e2 100644 --- a/packages/web/src/components/automations/use-automation-targets.ts +++ b/packages/web/src/components/automations/use-automation-targets.ts @@ -52,6 +52,8 @@ export interface UseAutomationTargetsResult { toggleEnvironment: (environmentId: string) => void; /** The "No repository" selection; ignored while a repository is required. */ clearTargets: () => void; + /** Scope changes discard targets, including required repositories. */ + resetTargets: () => void; /** Switches single/multi-select, collapsing a multi-selection to one target. */ toggleSelectionMode: () => void; /** The `repositories` payload field: full selection with branch rules applied. */ @@ -209,6 +211,10 @@ export function useAutomationTargets( toggleRepository, toggleEnvironment, clearTargets, + resetTargets: () => { + commitTargets([]); + setBaseBranch(""); + }, toggleSelectionMode, buildRepositoriesPayload: buildPayload, }; diff --git a/packages/web/src/components/resource-team-field.tsx b/packages/web/src/components/resource-team-field.tsx new file mode 100644 index 0000000000..c2370df2ad --- /dev/null +++ b/packages/web/src/components/resource-team-field.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useId } from "react"; +import type { TeamResponse } from "@/hooks/use-teams"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +// Radix reserves the empty string for "no selection", so workspace ownership needs a sentinel. +const WORKSPACE_VALUE = "workspace"; + +export function ResourceTeamField({ + teamId, + teams, + allTeams, + disabled, + loading, + error, + onChange, + allowWorkspace = true, +}: { + teamId: string | null; + teams: Pick[]; + allTeams: Pick[]; + disabled: boolean; + loading: boolean; + error: unknown; + onChange: (teamId: string | null) => void; + allowWorkspace?: boolean; +}) { + const id = useId(); + return ( +
+ + + {error ? ( +

+ Unable to load teams. +

+ ) : null} + {!allowWorkspace && !teamId && !error && ( +

A team is required.

+ )} +
+ ); +} diff --git a/packages/web/src/components/session-header.test.tsx b/packages/web/src/components/session-header.test.tsx index 40783eb919..8d4bae8c99 100644 --- a/packages/web/src/components/session-header.test.tsx +++ b/packages/web/src/components/session-header.test.tsx @@ -103,6 +103,7 @@ describe("SessionHeader", () => { onToggleDetails: vi.fn(), onToggleDesktopDetails: vi.fn(), onOpenMobileDetails: vi.fn(), + onOpenMobileMedia: vi.fn(), actions, renameSession: vi.fn(), }; @@ -130,6 +131,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -161,6 +163,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={{ ...actions, capabilities: { ...FULL_CAPABILITIES, lifecycle: false } }} renameSession={vi.fn()} capabilities={{ @@ -199,6 +202,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={onToggleDesktopDetails} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -229,6 +233,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={onToggleDesktopDetails} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -254,6 +259,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -267,6 +273,7 @@ describe("SessionHeader", () => { it("replaces the phone Details control with the unified actions menu", () => { const onToggleDetails = vi.fn(); const onOpenMobileDetails = vi.fn(); + const onOpenMobileMedia = vi.fn(); render( { onToggleDetails={onToggleDetails} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={onOpenMobileDetails} - actions={actions} + onOpenMobileMedia={onOpenMobileMedia} + actions={{ + ...actions, + artifacts: [{ id: "shot-1", type: "screenshot", url: null, createdAt: 1 }], + }} renameSession={vi.fn()} /> ); @@ -297,6 +308,11 @@ describe("SessionHeader", () => { fireEvent.click(screen.getByRole("menuitem", { name: "Details" })); expect(onOpenMobileDetails).toHaveBeenCalledOnce(); expect(onToggleDetails).not.toHaveBeenCalled(); + + fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false }); + fireEvent.click(screen.getByRole("menuitem", { name: "Media (1)" })); + expect(onOpenMobileMedia).toHaveBeenCalledOnce(); + expect(onOpenMobileDetails).toHaveBeenCalledOnce(); }); it("renders separate status icons and reveals the connection label on hover", async () => { @@ -313,6 +329,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -344,6 +361,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -372,6 +390,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -405,6 +424,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -441,6 +461,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -475,6 +496,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -507,6 +529,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -537,6 +560,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -565,6 +589,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -593,6 +618,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -618,6 +644,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -638,6 +665,7 @@ describe("SessionHeader", () => { onToggleDetails: vi.fn(), onToggleDesktopDetails: vi.fn(), onOpenMobileDetails: vi.fn(), + onOpenMobileMedia: vi.fn(), actions, renameSession: vi.fn(), }; @@ -664,6 +692,7 @@ describe("SessionHeader", () => { onToggleDetails: vi.fn(), onToggleDesktopDetails: vi.fn(), onOpenMobileDetails: vi.fn(), + onOpenMobileMedia: vi.fn(), actions, renameSession: vi.fn(), }; @@ -691,6 +720,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -736,6 +766,7 @@ describe("SessionHeader mobile presentation", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -865,6 +896,7 @@ describe("SessionHeader mobile presentation", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> diff --git a/packages/web/src/components/session-header.tsx b/packages/web/src/components/session-header.tsx index 578d5cbd2e..f8a664f645 100644 --- a/packages/web/src/components/session-header.tsx +++ b/packages/web/src/components/session-header.tsx @@ -140,6 +140,8 @@ export type SessionHeaderProps = { onToggleDetails: () => void; onToggleDesktopDetails: () => void; onOpenMobileDetails: () => void; + /** Opens the details overlay on the section that lists captured media. */ + onOpenMobileMedia: () => void; actions: SessionActionProps; optimisticTitle?: string; renameSession: (title: string) => Promise; @@ -162,6 +164,7 @@ export function SessionHeader({ onToggleDetails, onToggleDesktopDetails, onOpenMobileDetails, + onOpenMobileMedia, actions, optimisticTitle, renameSession, @@ -288,7 +291,7 @@ export function SessionHeader({ sandbox={sandbox} triggerRef={actionsButtonRef} onOpenDetails={onOpenMobileDetails} - onOpenMedia={onOpenMobileDetails} + onOpenMedia={onOpenMobileMedia} />
{capabilities.read && ( diff --git a/packages/web/src/components/session-right-sidebar.test.tsx b/packages/web/src/components/session-right-sidebar.test.tsx index a12ecddb9f..4f76d1461f 100644 --- a/packages/web/src/components/session-right-sidebar.test.tsx +++ b/packages/web/src/components/session-right-sidebar.test.tsx @@ -591,37 +591,40 @@ describe("SessionRightSidebar", () => { it("uses linked, keyboard-accessible tabs with one visible panel", async () => { const user = userEvent.setup(); render(inspector()); - const changes = screen.getByRole("tab", { name: "Changes" }); - expect(changes).toHaveAttribute("aria-selected", "true"); + const tabs = screen.getAllByRole("tab").map((tab) => tab.textContent?.trim()); + expect(tabs).toEqual(["Info", "Changes", "Tasks", "Tools"]); + const info = screen.getByRole("tab", { name: "Info" }); + expect(info).toHaveAttribute("aria-selected", "true"); expect(screen.getAllByRole("tabpanel")).toHaveLength(1); + expect(screen.getByText("Run information")).toBeVisible(); - await user.click(changes); + await user.click(info); await user.keyboard("{ArrowRight}"); - const info = screen.getByRole("tab", { name: "Info" }); - await waitFor(() => expect(info).toHaveFocus()); - expect(info).toHaveAttribute("aria-selected", "true"); - expect(changes).toHaveAttribute("tabindex", "-1"); - expect(screen.getByRole("tabpanel", { name: "Info" })).toHaveAttribute( + const changes = screen.getByRole("tab", { name: "Changes" }); + await waitFor(() => expect(changes).toHaveFocus()); + expect(changes).toHaveAttribute("aria-selected", "true"); + expect(info).toHaveAttribute("tabindex", "-1"); + expect(screen.getByRole("tabpanel", { name: "Changes" })).toHaveAttribute( "id", - info.getAttribute("aria-controls") + changes.getAttribute("aria-controls") ); - expect(screen.getByText("Run information")).toBeVisible(); const tools = screen.getByRole("tab", { name: "Tools" }); await user.keyboard("{End}"); await waitFor(() => expect(tools).toHaveFocus()); await user.keyboard("{ArrowRight}"); - await waitFor(() => expect(changes).toHaveFocus()); + await waitFor(() => expect(info).toHaveFocus()); await user.keyboard("{ArrowLeft}"); await waitFor(() => expect(tools).toHaveFocus()); await user.keyboard("{Home}"); - await waitFor(() => expect(changes).toHaveFocus()); + await waitFor(() => expect(info).toHaveFocus()); }); it("preserves the file filter when switching panels and passes canonical selection", async () => { const user = userEvent.setup(); const onOpenDiff = vi.fn(); render(inspector({ diffState: READY_DIFF, onOpenDiff })); + selectTab("Changes 1"); const filter = screen.getByRole("searchbox", { name: "Filter changed files" }); await user.type(filter, "navigation"); await user.click(screen.getByRole("tab", { name: "Info" })); @@ -641,6 +644,7 @@ describe("SessionRightSidebar", () => { it("totals the latest changes in the Changes header", () => { render(inspector({ diffState: READY_DIFF })); + selectTab("Changes 1"); const panel = screen.getByRole("tabpanel", { name: "Changes 1" }); expect(panel).toHaveTextContent("+2"); @@ -681,6 +685,7 @@ describe("SessionRightSidebar", () => { "preserves diff lifecycle state %#", (props, message) => { render(inspector(props)); + selectTab(/^Changes/); expect(screen.getByRole("tabpanel", { name: /^Changes/ })).toHaveTextContent(message); } ); @@ -692,18 +697,34 @@ describe("SessionRightSidebar", () => { capabilities: { ...FULL_CAPABILITIES, lifecycle: false }, }) ); + selectTab("Changes"); expect(screen.getByText("Capture failed")).toBeVisible(); expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument(); }); - it("lists captured media with the changes", () => { + it("lists captured media as collapsible artifacts in Info", () => { render( inspector({ - artifacts: [{ id: "shot-1", type: "screenshot", url: null, createdAt: 1 }], + artifacts: [ + { + id: "shot-1", + type: "screenshot", + url: null, + createdAt: 1, + metadata: { caption: "Login page" }, + }, + ], }) ); - expect(screen.getByRole("tabpanel", { name: /^Changes/ })).toHaveTextContent("Media (1)"); + const toggle = screen.getByRole("button", { name: "Artifacts (1)" }); + expect(screen.getByRole("tabpanel", { name: "Info" })).toContainElement(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("button", { name: "Login page" })).toBeVisible(); + + fireEvent.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByRole("button", { name: "Login page" })).not.toBeInTheDocument(); }); it("shows honest task and tool empty states", () => { diff --git a/packages/web/src/components/session-right-sidebar.tsx b/packages/web/src/components/session-right-sidebar.tsx index b131574f3e..c6f2a29281 100644 --- a/packages/web/src/components/session-right-sidebar.tsx +++ b/packages/web/src/components/session-right-sidebar.tsx @@ -71,8 +71,8 @@ const DEFAULT_CAN_MANAGE_BUDGET = false; const TRACE_DOWNLOAD_TIMEOUT_MS = 60_000; const INSPECTOR_TAB_LABELS: Record = { - changes: "Changes", info: "Info", + changes: "Changes", tasks: "Tasks", tools: "Tools", }; @@ -226,7 +226,7 @@ export function SessionRightSidebarContent({ ))} - {/* Canonical durable checkout changes, plus media the agent captured */} + {/* Canonical durable checkout changes */}

Files changed

@@ -288,17 +288,6 @@ export function SessionRightSidebarContent({ /> )}
- {mediaArtifacts.length > 0 && ( -
- - - -
- )}
@@ -330,6 +319,16 @@ export function SessionRightSidebarContent({ canManageBudget={canManageBudget} /> + {/* Media the agent captured */} + {mediaArtifacts.length > 0 && ( + + + + )} {scope && capabilities.changeVisibility && ( new Map(environments.map((environment) => [environment.id, environment.name])), [environments] diff --git a/packages/web/src/components/session-target-picker.test.tsx b/packages/web/src/components/session-target-picker.test.tsx new file mode 100644 index 0000000000..a341fdf816 --- /dev/null +++ b/packages/web/src/components/session-target-picker.test.tsx @@ -0,0 +1,121 @@ +// @vitest-environment jsdom +/// + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import type { SessionTargetPickerProps } from "@/hooks/use-session-target-picker"; +import { + MULTIPLE_REPOSITORIES_OPTION_VALUE, + NO_REPOSITORY_OPTION_VALUE, +} from "@/lib/session-target"; +import { SessionTargetPicker } from "./session-target-picker"; + +expect.extend(matchers); +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn(); +}); +afterEach(cleanup); + +describe("SessionTargetPicker empty grants", () => { + it("renders the explicit grant error alongside usable target controls", () => { + const select = vi.fn(); + const props: SessionTargetPickerProps = { + sessionTarget: null, + targetSelectValue: "", + targetOptions: [{ value: NO_REPOSITORY_OPTION_VALUE, label: "No repository" }], + displayTargetName: "Select repo", + onTargetSelectValueChange: select, + onMultiSelectionChange: vi.fn(), + selectedBranch: "", + setSelectedBranch: vi.fn(), + branches: [], + loadingBranches: false, + repos: [], + loadingRepos: false, + repositoryGrantError: "This team has no repository grants.", + selectionError: null, + }; + render(); + expect(screen.getByRole("alert")).toHaveTextContent("This team has no repository grants."); + fireEvent.click(screen.getByRole("button", { name: "Select repo" })); + fireEvent.click(screen.getByRole("option", { name: "No repository" })); + expect(select).toHaveBeenCalledWith(NO_REPOSITORY_OPTION_VALUE); + }); +}); + +describe("SessionTargetPicker invalidated selections", () => { + const props: SessionTargetPickerProps = { + sessionTarget: { kind: "repo", repoFullName: "acme/web" }, + targetSelectValue: "acme/web", + targetOptions: [{ value: NO_REPOSITORY_OPTION_VALUE, label: "No repository" }], + displayTargetName: "acme/web", + onTargetSelectValueChange: vi.fn(), + onMultiSelectionChange: vi.fn(), + selectedBranch: "feature", + setSelectedBranch: vi.fn(), + branches: [{ name: "feature" }], + loadingBranches: false, + repos: [], + loadingRepos: false, + repositoryGrantError: null, + selectionError: "Your selected target is no longer available. Choose a target again.", + }; + + it("shows the retained target and invalidation message while allowing a new target choice", () => { + const select = vi.fn(); + render(); + expect(screen.getByRole("alert")).toHaveTextContent(/choose a target again/i); + expect(screen.getByRole("button", { name: "feature" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "acme/web" })); + fireEvent.click(screen.getByRole("option", { name: "No repository" })); + expect(select).toHaveBeenCalledWith(NO_REPOSITORY_OPTION_VALUE); + }); + + it("keeps the exact multi-repository list visible and supports explicitly clearing it", () => { + const selectRepositories = vi.fn(); + render( + + ); + expect(screen.getByRole("button", { name: "Repository selection" })).toHaveTextContent( + "acme/api, acme/web" + ); + fireEvent.click(screen.getByRole("button", { name: "Choose repositories again" })); + expect(selectRepositories).toHaveBeenCalledWith([]); + }); + + it("removes the invalidation message after a new explicit choice", () => { + const { rerender } = render(); + rerender( + + ); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("keeps the recovery action disabled when the composer is disabled", () => { + render( + + ); + expect(screen.getByRole("button", { name: "Choose repositories again" })).toBeDisabled(); + }); +}); diff --git a/packages/web/src/components/session-target-picker.tsx b/packages/web/src/components/session-target-picker.tsx index b8c6c11b62..55b933a69d 100644 --- a/packages/web/src/components/session-target-picker.tsx +++ b/packages/web/src/components/session-target-picker.tsx @@ -23,6 +23,8 @@ export function SessionTargetPicker({ loadingBranches, repos, loadingRepos, + repositoryGrantError, + selectionError, disabled, }: SessionTargetPickerProps & { disabled: boolean }) { return ( @@ -50,6 +52,18 @@ export function SessionTargetPicker({ + {repositoryGrantError && ( +

+ {repositoryGrantError} +

+ )} + + {selectionError && ( +

+ {selectionError} +

+ )} + {/* Ad-hoc repository set editor */} {sessionTarget?.kind === "repos" && ( )} + {selectionError && sessionTarget?.kind === "repos" && ( + + )} + {/* Branch selector */} {sessionTarget?.kind === "repo" && ( option.label.toLowerCase().includes(query)} direction="down" dropdownWidth="w-56" - disabled={disabled || loadingBranches} + disabled={disabled || loadingBranches || loadingRepos || !!selectionError} triggerClassName="flex max-w-full items-center gap-1 text-sm text-muted-foreground hover:text-foreground disabled:opacity-50 disabled:cursor-not-allowed transition" > diff --git a/packages/web/src/components/settings/audit-log-settings.test.tsx b/packages/web/src/components/settings/audit-log-settings.test.tsx index 32ddb442fc..fe6a86a1ac 100644 --- a/packages/web/src/components/settings/audit-log-settings.test.tsx +++ b/packages/web/src/components/settings/audit-log-settings.test.tsx @@ -195,9 +195,12 @@ describe("AuditLogSettings", () => { }); it.each([ + ["team.grant_added", "Team repository grant added"], + ["team.grant_removed", "Team repository grant removed"], ["team.secret_set", "Team secret set"], ["team.secret_deleted", "Team secret deleted"], - ])("labels %s as an applied operation", (action, label) => { + ["automation.executor_changed", "Automation executor changed"], + ])("labels %s as an operation in the workspace audit viewer", (action, label) => { const article = renderSingle(createEvent("applied", { action })); expect(article.getByText(label)).toBeInTheDocument(); expect(article.getByText("Applied")).toBeInTheDocument(); diff --git a/packages/web/src/components/settings/audit-log-settings.tsx b/packages/web/src/components/settings/audit-log-settings.tsx index 1a0e4d26b4..7070522e0c 100644 --- a/packages/web/src/components/settings/audit-log-settings.tsx +++ b/packages/web/src/components/settings/audit-log-settings.tsx @@ -62,8 +62,11 @@ const OPERATION_LABELS: Record = { "team.member_role_changed": "Team member role changed", "team.member_removed": "Team member removed", "team.member_joined": "Team member joined", + "team.grant_added": "Team repository grant added", + "team.grant_removed": "Team repository grant removed", "team.secret_set": "Team secret set", "team.secret_deleted": "Team secret deleted", + "automation.executor_changed": "Automation executor changed", }; const ACTION_LABELS = new Map([ diff --git a/packages/web/src/components/settings/environment-access.test.ts b/packages/web/src/components/settings/environment-access.test.ts new file mode 100644 index 0000000000..8e4a0302ea --- /dev/null +++ b/packages/web/src/components/settings/environment-access.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import type { Environment } from "@open-inspect/shared/types/environments"; +import { environmentAccess, type EnvironmentFeatureGrants } from "./environment-access"; + +const allGrants: EnvironmentFeatureGrants = { + manageSecrets: true, + manageRepoSecrets: true, + manageSettings: true, + manageImages: true, + readImages: true, + readSettings: true, +}; + +function environment(capabilities?: Environment["capabilities"]): Environment { + return { + id: "env-1", + name: "Stack", + description: null, + prebuildEnabled: true, + createdAt: 1, + updatedAt: 1, + repositories: [], + capabilities, + }; +} + +describe("environmentAccess", () => { + it("grants nothing without server capabilities, whatever the feature grants", () => { + expect(environmentAccess(environment(), allGrants)).toEqual({ + canManage: false, + canEditSecrets: false, + canImportRepoSecrets: false, + canEditOverrides: false, + canViewImage: false, + canRebuild: false, + tabs: [], + }); + }); + + it("lets readers view secrets and overrides without editing them", () => { + const access = environmentAccess( + environment({ canRead: true, canManage: false, canUse: true }), + allGrants + ); + expect(access.tabs).toEqual(["secrets", "overrides"]); + expect(access).toMatchObject({ canEditSecrets: false, canEditOverrides: false }); + expect(access.canViewImage).toBe(true); + }); + + it("requires the feature grant alongside environment management", () => { + const access = environmentAccess( + environment({ canRead: true, canManage: true, canUse: true }), + { ...allGrants, manageSecrets: false, manageImages: false } + ); + expect(access.tabs).toEqual(["configuration", "overrides"]); + expect(access).toMatchObject({ + canManage: true, + canEditSecrets: false, + canImportRepoSecrets: false, + canRebuild: false, + canEditOverrides: true, + }); + }); +}); diff --git a/packages/web/src/components/settings/environment-access.ts b/packages/web/src/components/settings/environment-access.ts new file mode 100644 index 0000000000..6e9e6de3b0 --- /dev/null +++ b/packages/web/src/components/settings/environment-access.ts @@ -0,0 +1,49 @@ +import type { Environment } from "@open-inspect/shared/types/environments"; + +export type EnvironmentTab = "configuration" | "secrets" | "overrides"; + +/** Workspace feature grants that environment actions require on top of environment access. */ +export interface EnvironmentFeatureGrants { + manageSecrets: boolean; + manageRepoSecrets: boolean; + manageSettings: boolean; + manageImages: boolean; + readImages: boolean; + readSettings: boolean; +} + +export interface EnvironmentAccess { + canManage: boolean; + canEditSecrets: boolean; + canImportRepoSecrets: boolean; + canEditOverrides: boolean; + canViewImage: boolean; + canRebuild: boolean; + /** Detail tabs the viewer may open, in display order; empty means no detail view. */ + tabs: EnvironmentTab[]; +} + +/** + * What the viewer may do with one environment: the server's capabilities for that environment + * combined with the feature grant each action also needs. Missing capabilities grant nothing. + */ +export function environmentAccess( + environment: Environment, + grants: EnvironmentFeatureGrants +): EnvironmentAccess { + const canManage = environment.capabilities?.canManage === true; + const canRead = environment.capabilities?.canRead === true; + const tabs: EnvironmentTab[] = []; + if (canManage) tabs.push("configuration"); + if (canRead && grants.manageSecrets) tabs.push("secrets"); + if (canRead && grants.readSettings) tabs.push("overrides"); + return { + canManage, + canEditSecrets: canManage && grants.manageSecrets, + canImportRepoSecrets: canManage && grants.manageSecrets && grants.manageRepoSecrets, + canEditOverrides: canManage && grants.manageSettings, + canViewImage: canRead && grants.readImages, + canRebuild: canManage && grants.manageImages, + tabs, + }; +} diff --git a/packages/web/src/components/settings/environment-detail.tsx b/packages/web/src/components/settings/environment-detail.tsx new file mode 100644 index 0000000000..fbac8a753b --- /dev/null +++ b/packages/web/src/components/settings/environment-detail.tsx @@ -0,0 +1,106 @@ +"use client"; + +import type { Environment } from "@open-inspect/shared/types/environments"; +import { Button } from "@/components/ui/button"; +import { ErrorBanner } from "@/components/ui/error-banner"; +import { SecretsEditor } from "@/components/secrets-editor"; +import { EnvironmentForm, type EnvironmentFormValues } from "./environment-form"; +import { EnvironmentIntegrationSettings } from "./environment-integration-settings"; +import { EnvironmentSecretsImport } from "./environment-secrets-import"; +import type { EnvironmentAccess, EnvironmentTab } from "./environment-access"; + +/** One environment's configuration, secrets, and overrides tabs, limited to what access allows. */ +export function EnvironmentDetail({ + environment, + access, + tab, + error, + submitting, + onTabChange, + onSubmit, + onBack, +}: { + environment: Environment; + access: EnvironmentAccess; + tab: EnvironmentTab; + error: string; + submitting: boolean; + onTabChange: (tab: EnvironmentTab) => void; + onSubmit: (values: EnvironmentFormValues) => void; + onBack: () => void; +}) { + const backButton = ( + + ); + const activeTab = access.tabs.includes(tab) ? tab : access.tabs[0]; + if (!activeTab) return backButton; + + return ( +
+

{environment.name}

+

+ {environment.description || "Edit this environment."} +

+ +
+ {access.tabs.map((entry) => ( + + ))} +
+ + {error && {error}} + + {activeTab === "configuration" ? ( + + ) : activeTab === "secrets" ? ( +
+

+ Sessions launched from this environment get global secrets plus these — repository + secrets do not carry over automatically. Changing secrets invalidates prebuilt images + and triggers a rebuild. +

+ + {access.canImportRepoSecrets && ( + + )} +
{backButton}
+
+ ) : ( +
+ +
{backButton}
+
+ )} +
+ ); +} diff --git a/packages/web/src/components/settings/environment-form.test.tsx b/packages/web/src/components/settings/environment-form.test.tsx index b70be9b743..527dc0abec 100644 --- a/packages/web/src/components/settings/environment-form.test.tsx +++ b/packages/web/src/components/settings/environment-form.test.tsx @@ -1,8 +1,8 @@ // @vitest-environment jsdom /// -import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen, within } from "@testing-library/react"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import * as matchers from "@testing-library/jest-dom/matchers"; import { MAX_TARGET_REPOSITORIES } from "@open-inspect/shared/types/repositories"; @@ -14,6 +14,8 @@ expect.extend(matchers); afterEach(cleanup); const mocks = vi.hoisted(() => ({ + useRepos: vi.fn(), + allowWorkspace: true, reposValue: [] as Array<{ id: number; fullName: string; @@ -26,7 +28,22 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("@/hooks/use-repos", () => ({ - useRepos: () => ({ repos: mocks.reposValue, loading: false }), + useRepos: (enabled: boolean, teamId: string | null) => { + mocks.useRepos(enabled, teamId); + return { repos: mocks.reposValue, loading: false }; + }, +})); +vi.mock("@/hooks/use-resource-teams", () => ({ + useResourceTeams: () => ({ + teams: [ + { id: "team-1", name: "Engineering" }, + { id: "team-2", name: "Design" }, + ], + allTeams: [], + loading: false, + error: null, + allowWorkspace: mocks.allowWorkspace, + }), })); vi.mock("@/hooks/use-branches", () => ({ @@ -34,6 +51,9 @@ vi.mock("@/hooks/use-branches", () => ({ })); beforeAll(() => { + // Radix Select uses pointer capture, which jsdom lacks. + Element.prototype.hasPointerCapture = () => false; + Element.prototype.releasePointerCapture = () => {}; Element.prototype.scrollIntoView = vi.fn(); // Radix Switch measures itself via ResizeObserver, which jsdom lacks. vi.stubGlobal( @@ -46,6 +66,10 @@ beforeAll(() => { ); }); +beforeEach(() => { + mocks.allowWorkspace = true; +}); + function repo(owner: string, name: string, id: number) { return { id, @@ -79,6 +103,85 @@ function environment( } describe("EnvironmentForm", () => { + it.each(["team-1", null])("validates owner %s and discards stale selections", async (teamId) => { + mocks.allowWorkspace = teamId !== null; + mocks.reposValue = [repo("acme", "web", 1)]; + const onSubmit = vi.fn(); + const user = userEvent.setup(); + const { container } = render( + + ); + expect(mocks.useRepos).toHaveBeenLastCalledWith(true, teamId); + expect(screen.getByRole("combobox", { name: "Team" })).toHaveTextContent( + teamId ? "Engineering" : "Select a team" + ); + fireEvent.submit(container.querySelector("form")!); + if (!teamId) { + expect(screen.getByRole("button", { name: "Create environment" })).toBeDisabled(); + expect(onSubmit).not.toHaveBeenCalled(); + return; + } + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ teamId })); + // Creating from a team's page keeps the environment on that page. + expect(screen.getByRole("combobox", { name: "Team" })).toBeDisabled(); + await user.click(screen.getByRole("combobox", { name: "Team" })); + expect(screen.queryByRole("option", { name: "Design" })).not.toBeInTheDocument(); + }); + + it("discards stale selections when an unscoped creation changes owner", async () => { + mocks.allowWorkspace = true; + mocks.reposValue = [repo("acme", "web", 1)]; + const onSubmit = vi.fn(); + const user = userEvent.setup(); + const { container } = render( + + ); + await user.click(screen.getByRole("combobox", { name: "Team" })); + await user.click(screen.getByRole("option", { name: "Design" })); + expect(mocks.useRepos).toHaveBeenLastCalledWith(true, "team-2"); + expect(screen.queryByTitle("acme/web")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Create environment" })).toBeDisabled(); + fireEvent.submit(container.querySelector("form")!); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("shows existing team ownership read-only and excludes it from edit submissions", () => { + mocks.reposValue = [repo("acme", "web", 1)]; + const onSubmit = vi.fn(); + const { container } = render( + + ); + expect(screen.getByRole("combobox", { name: "Team" })).toHaveTextContent("Engineering"); + expect(screen.getByRole("combobox", { name: "Team" })).toBeDisabled(); + fireEvent.submit(container.querySelector("form")!); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0]).not.toHaveProperty("teamId"); + expect(onSubmit.mock.calls[0][0].repositories).toEqual([ + { repoOwner: "acme", repoName: "web", baseBranch: "main" }, + ]); + }); + it("preserves a nested owner namespace when saving", async () => { mocks.reposValue = [repo("group/subgroup", "web", 1)]; const onSubmit = vi.fn(); diff --git a/packages/web/src/components/settings/environment-form.tsx b/packages/web/src/components/settings/environment-form.tsx index 8946f5ea1f..e204beea3d 100644 --- a/packages/web/src/components/settings/environment-form.tsx +++ b/packages/web/src/components/settings/environment-form.tsx @@ -18,11 +18,14 @@ import { Combobox } from "@/components/ui/combobox"; import { BranchIcon, ChevronDownIcon, RepoIcon } from "@/components/ui/icons"; import { useBranches } from "@/hooks/use-branches"; import { useRepos, type Repo } from "@/hooks/use-repos"; +import { useResourceTeams } from "@/hooks/use-resource-teams"; +import { ResourceTeamField } from "@/components/resource-team-field"; import { RepositoryMultiSelect } from "@/components/repository-multi-select"; import { repositorySelectionKey } from "@/lib/repository-selection"; import { getRepoImageProviders, supportsRepoImages } from "@/lib/sandbox-provider"; export interface EnvironmentFormValues { + teamId?: string | null; name: string; description: string | null; prebuildEnabled: boolean; @@ -42,14 +45,25 @@ export function EnvironmentForm({ onSubmit, onCancel, submitting, + teamId: initialTeamId, }: { mode: "create" | "edit"; initialValues?: Environment; onSubmit: (values: EnvironmentFormValues) => void; onCancel: () => void; submitting: boolean; + teamId?: string | null; }) { - const { repos, loading: loadingRepos } = useRepos(); + const [teamId, setTeamId] = useState(initialValues?.ownerTeamId ?? initialTeamId ?? null); + // Owner is fixed when editing, or when creating from a team's page. + const ownerLocked = mode === "edit" || !!initialTeamId; + const scope = useResourceTeams("environment"); + const scopeValid = + mode === "edit" || + (!scope.loading && + !scope.error && + (teamId ? scope.teams.some((team) => team.id === teamId) : scope.allowWorkspace)); + const { repos, loading: loadingRepos } = useRepos(true, teamId); const prebuildsSupported = supportsRepoImages(); const [name, setName] = useState(initialValues?.name ?? ""); @@ -107,12 +121,13 @@ export function EnvironmentForm({ setSelectedKeys((current) => current.filter((entry) => entry !== key)); }; - const canSubmit = name.trim().length > 0 && selectedKeys.length > 0 && !submitting; + const canSubmit = name.trim().length > 0 && selectedKeys.length > 0 && !submitting && scopeValid; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!canSubmit) return; onSubmit({ + ...(mode === "create" ? { teamId } : {}), name: name.trim(), description: description.trim() ? description.trim() : null, prebuildEnabled, @@ -137,6 +152,18 @@ export function EnvironmentForm({ return ( + { + if (submitting || ownerLocked) return; + setTeamId(nextTeamId); + setSelectedKeys([]); + setBranchByKey({}); + }} + />