# InfraBridge API

> Internal infrastructure management API for Virsec. Wraps the Pulumi
> Automation API so services and AI agents can manage cloud infrastructure
> through a single REST endpoint.

Base URL: https://infra.us1.virsec.com

## Authentication

All endpoints except `GET /health` and `GET /` require a Bearer token:

    Authorization: Bearer <token>

Token types:
- **Superadmin** — full access to all projects and token management.
- **Scoped** — limited to specific projects with read and/or write permissions.
- **Granular** — scoped to a specific program and/or specific stacks with
  fine-grained permissions (e.g. `connectors:read`, `connectors:write`).

Permission hierarchy (coarse includes fine-grained):
- `read` includes `config:read`, `connectors:read`, `jobs:read`
- `write` includes `config:write`, `connectors:write`, `up`, `preview`, `refresh`

Scope fields:
- `project` (required) — project name (e.g. `otto`)
- `program` (optional) — restrict to a program (e.g. `tenant`). Omit for all programs.
- `allowed_stacks` (optional) — explicit stack whitelist (e.g. `["dev26"]`). Must be non-empty.
- `platform` (optional) — dynamic tenant scoping by parent platform stack (e.g. `"ob"`).
  Automatically resolves to all tenant stacks linked to that platform. Only valid with
  `program="tenant"`. Mutually exclusive with `allowed_stacks`.
- `permissions` (required) — list of permissions

Scoped token examples:

Connector-only access to all tenant stacks:
```json
{"name":"otto-app","scopes":[{"project":"otto","program":"tenant","permissions":["connectors:read","connectors:write"]}]}
```

Dev-only access (platform + all dev tenants):
```json
{"name":"dev-env","scopes":[
  {"project":"otto","program":"platform","allowed_stacks":["ob"],"permissions":["read","write"]},
  {"project":"otto","program":"tenant","platform":"ob","permissions":["read","write","connectors:read","connectors:write"]}
]}
```

## Resource Hierarchy

    /v1/projects/{project}/{program}/{stack}

- **Project** — top-level grouping (e.g. `otto`)
- **Program** — a Pulumi program within a project (e.g. `platform`, `tenant`)
- **Stack** — a named instance of a program (e.g. `ob`, `prod`, `dev26`)

## Quick Start

```bash
# List projects
curl -H "Authorization: Bearer $TOKEN" https://infra.us1.virsec.com/v1/projects

# List stacks for a program
curl -H "Authorization: Bearer $TOKEN" https://infra.us1.virsec.com/v1/projects/otto/tenant/stacks

# Read config for a stack
curl -H "Authorization: Bearer $TOKEN" \
  https://infra.us1.virsec.com/v1/projects/otto/tenant/dev26/config

# Set a config value
curl -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"value":"my-value","secret":false}' \
  https://infra.us1.virsec.com/v1/projects/otto/tenant/dev26/config/otto-tenant:my_key?wait=true

# Run preview
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"message":"checking changes"}' \
  https://infra.us1.virsec.com/v1/projects/otto/tenant/dev26/preview?wait=true&timeout=110

# Cancel a stuck job and clear the Pulumi lock
curl -X POST -H "Authorization: Bearer $TOKEN" \
  https://infra.us1.virsec.com/v1/projects/otto/tenant/dev26/cancel
```

## Endpoints

### Health & Discovery

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/health` | none | Health check. Returns `{"status":"healthy"}` |
| GET | `/` | none | This document |
| GET | `/v1/` | any | JSON discovery with links to all resources |
| GET | `/v1/openapi.json` | none | OpenAPI 3.1 spec (JSON) for MCP tool generation and agent function calling |

### Token Management (superadmin only)

| Method | Path | Description |
|--------|------|-------------|
| POST | `/v1/tokens` | Create a scoped token. Body: `{"name":"...","scopes":[{"project":"otto","program":"tenant","platform":"ob","permissions":["read","write"]}]}` |
| GET | `/v1/tokens` | List all tokens |
| PATCH | `/v1/tokens/{token_id}` | Update token scopes. Body: `{"scopes":[...]}` |
| DELETE | `/v1/tokens/{token_id}` | Revoke a token |

### Projects & Stacks

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/v1/projects` | any | List all projects |
| GET | `/v1/projects/{project}` | read | Project detail with programs |
| GET | `/v1/projects/{project}/{program}/stacks` | read | List stacks from Pulumi backend |
| GET | `/v1/projects/{project}/{program}/{stack}` | read | Stack detail with links to config, jobs, operations |
| GET | `.../resources` | read | Export stack state — structured list of all resources with URNs, types, names, provider, protect flags |

### Resources

Base: `/v1/projects/{project}/{program}/{stack}`

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `.../resources` | read | Export full stack state as a structured resource list |

Returns all deployed resources from the Pulumi state (S3 checkpoint). Read-only,
does not lock the stack.

Response:
```json
{
  "ok": true,
  "data": {
    "stack": "otto/tenant/dev26",
    "resource_count": 97,
    "resources": [
      {
        "urn": "urn:pulumi:dev26::otto-tenant::aws:s3/bucket:Bucket::data-bucket",
        "type": "aws:s3/bucket:Bucket",
        "name": "data-bucket",
        "provider": "aws",
        "parent": "urn:pulumi:dev26::otto-tenant::pulumi:pulumi:Stack::otto-tenant-dev26",
        "protect": false
      }
    ]
  }
}
```

```bash
# List all resources in a stack
curl -H "Authorization: Bearer $TOKEN" \
  https://infra.us1.virsec.com/v1/projects/otto/tenant/dev26/resources
```

### Config

Base: `/v1/projects/{project}/{program}/{stack}`

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `.../config` | read | All config keys. Secrets redacted unless `?reveal=true` |
| GET | `.../config/{key}` | read | Single config key |
| PUT | `.../config/{key}` | write | Set key. Body: `{"value":"...","secret":false}` |
| PUT | `.../config` | write | Bulk set. Body: `{"values":{"key":{"value":"...","secret":false}}}` |
| DELETE | `.../config/{key}` | write | Remove a key |

**Config namespaces:** Each program has a config namespace (e.g. `otto-pulumi` for
platform, `otto-tenant` for tenant). Bare keys like `domain` are auto-prefixed to
`otto-pulumi:domain`. You can also pass the full namespaced key directly.

Config `set` writes to the local stack config file. Changes are pushed to the S3
backend on the next `pulumi up` or `pulumi refresh`.

### Connectors (Business API)

Base: `/v1/projects/{project}/tenant/{stack}`

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `.../connectors` | `connectors:read` | List all connectors with enabled/active status and pipeline counts |
| GET | `.../connectors/{name}` | `connectors:read` | Single connector detail with full config |
| POST | `.../connectors/{name}/enable` | `connectors:write` | Enable connector + trigger `pulumi up` |
| POST | `.../connectors/{name}/disable` | `connectors:write` | Disable connector + trigger `pulumi up` |

Enable/disable endpoints support `?wait=true&timeout=N` like other write operations.
They modify `connectors.{name}.enabled` in the `otto-tenant:tenant` config blob and
run `pulumi up` to apply the change.

```bash
# List connectors for a tenant
curl -H "Authorization: Bearer $TOKEN" \
  https://infra.us1.virsec.com/v1/projects/otto/tenant/dev26/connectors

# Enable a connector
curl -X POST -H "Authorization: Bearer $TOKEN" \
  "https://infra.us1.virsec.com/v1/projects/otto/tenant/dev26/connectors/qualys/enable?wait=true&timeout=110"
```

### Pipelines (per-connector)

Base: `/v1/projects/{project}/tenant/{stack}/connectors/{name}`

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `.../pipelines` | `connectors:read` | List all pipelines with schedule and enabled status |
| GET | `.../pipelines/{pipeline}` | `connectors:read` | Single pipeline detail (schedule, command, environment) |
| PUT | `.../pipelines/{pipeline}` | `connectors:write` | Add or update pipeline + trigger `pulumi up` |
| DELETE | `.../pipelines/{pipeline}` | `connectors:write` | Remove pipeline + trigger `pulumi up` |
| POST | `.../pipelines/{pipeline}/enable` | `connectors:write` | Enable pipeline + trigger `pulumi up` |
| POST | `.../pipelines/{pipeline}/disable` | `connectors:write` | Disable pipeline + trigger `pulumi up` |

PUT body (only `schedule` is required):
```json
{"schedule":"cron(0 6,18 * * ? *)","enabled":true,"command":["--tenant-id","$TENANT_ID","--data-type","system-events"],"environment":{"DATA_TYPE":"system-events"},"task_role_arn":"$TASK_ROLE_ARN"}
```

```bash
# List pipelines for a connector
curl -H "Authorization: Bearer $TOKEN" \
  https://infra.us1.virsec.com/v1/projects/otto/tenant/dev26/connectors/virsecapi/pipelines

# Add a pipeline
curl -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"schedule":"cron(0 6,18 * * ? *)","command":["--tenant-id","$TENANT_ID","--since","12h","--data-type","system-events"],"environment":{"DATA_TYPE":"system-events"}}' \
  "https://infra.us1.virsec.com/v1/projects/otto/tenant/dev26/connectors/virsecapi/pipelines/system-events-sync?wait=true&timeout=110"

# Disable a pipeline
curl -X POST -H "Authorization: Bearer $TOKEN" \
  "https://infra.us1.virsec.com/v1/projects/otto/tenant/dev26/connectors/virsecapi/pipelines/system-events-sync/disable?wait=true"

# Remove a pipeline
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
  "https://infra.us1.virsec.com/v1/projects/otto/tenant/dev26/connectors/virsecapi/pipelines/system-events-sync?wait=true&timeout=110"
```

### Pulumi Operations

Base: `/v1/projects/{project}/{program}/{stack}`

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `.../up` | write | Run `pulumi up`. Body (optional): `{"message":"..."}` |
| POST | `.../preview` | write | Run `pulumi preview`. Body (optional): `{"message":"..."}` |
| POST | `.../refresh` | write | Run `pulumi refresh` |
| POST | `.../destroy` | write | Run `pulumi destroy`. Removes all stack resources. Body (optional): `{"message":"..."}` |
| POST | `.../cancel` | write | Cancel active job and clear Pulumi backend lock. Synchronous (200). No body needed |

### Jobs

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/v1/jobs` | any | List jobs. Filters: `?project=&program=&stack=&status=&limit=50` |
| GET | `/v1/jobs/{job_id}` | any | Job detail |
| GET | `.../jobs` | any | Stack-scoped job history |

## Async Jobs & Wait Mode

All mutating operations (config writes, up, preview, refresh) are asynchronous
and return **202** with a job ID. Use `?wait=true` to block until the job
completes (server-sent polling, not SSE).

    ?wait=true          Block until done (default timeout 30s)
    ?wait=true&timeout=110  Block up to 110s (maximum, ALB limit)

If the timeout is reached, the current job state is returned (the job continues
in the background). Poll `GET /v1/jobs/{job_id}` to check progress.

## Concurrency Control

Write operations are locked per stack. If a job is already running on a stack,
subsequent write requests return **409**:

```json
{
  "ok": false,
  "error": {
    "code": "STACK_LOCKED",
    "message": "Stack has an active job",
    "active_job_id": "job-abc123"
  }
}
```

Read operations (config:get) bypass the lock.

## Response Envelope

All responses use a standard envelope:

```json
{
  "ok": true,
  "data": { ... },
  "job": {
    "id": "job-abc123",
    "status": "succeeded",
    "action": "preview",
    "duration_ms": 42786
  },
  "error": null,
  "links": {
    "self": "/v1/projects/otto/tenant/dev26",
    "job": "/v1/jobs/job-abc123"
  }
}
```

## Error Codes

| HTTP | Code | Meaning |
|------|------|---------|
| 401 | UNAUTHORIZED | Missing or invalid token |
| 403 | FORBIDDEN | Token lacks required permission |
| 404 | NOT_FOUND | Project, program, stack, or config key not found |
| 409 | STACK_LOCKED | Another job is running on this stack |
| 429 | RATE_LIMITED | Too many requests (60/min general, 10/min token ops) |

## Rate Limits

- General endpoints: 60 requests/minute per token
- Token management: 10 requests/minute per token
