Public: anyone can read this; nobody can change it.
docs: enterprise-platform vision — Forgejo as the control plane #10
Check · Review · Land · Live — No review yet.
docs/enterprise-platform → main 1 commit
A guest
This pull comes from a guest: its preview runs without the app's variables or a forge token until a person who can write here approves it.
This pull waits for a person who can write here to review it; it never lands on its own.
4 files
changed +531
−0
docs/README.md added +24
| @@ -0,0 +1,24 @@ | ||
| 1 | # Platform docs | |
| 2 | ||
| 3 | **Start here:** [](./vision/enterprise-platform.md) — the | |
| 4 | north-star. One idea (Forgejo is the control plane), three pillars, the growth loop, and a | |
| 5 | roadmap of `agent-work`-able issues the platform can build on itself. | |
| 6 | ||
| 7 | Then the two design docs it points to: | |
| 8 | ||
| 9 | - [](./design/forgejo-rbac-and-access.md) — **pillars 1 & 2.** | |
| 10 | Apps private-by-default, gated on Forgejo repo permission at one Traefik `forwardAuth` | |
| 11 | edge; secrets and domains managed in Forgejo's own UI (Actions secrets / repo fields), | |
| 12 | write-gated for free. | |
| 13 | - [](./design/multitenancy-and-agent-identity.md) — **pillar 3.** | |
| 14 | Retire the admin god-PAT: the agent runs _as the user_ with a per-app, namespaced, | |
| 15 | read-only kube token; authz on repo permission not org membership; the few missing DOS | |
| 16 | bounds. Backed by local k3d experiments. | |
| 17 | ||
| 18 | Every claim is anchored in the current code, and the load-bearing mechanisms (the Forgejo | |
| 19 | permission API, native Forgejo secrets, ResourceQuota enforcement, a scoped per-app kube | |
| 20 | Role) were validated live against the platform and a throwaway cluster. | |
| 21 | ||
| 22 | > These refine the earlier per-issue proposals (self-serve secrets #2, domains #3) with the | |
| 23 | > Forgejo-RBAC lens — most notably: the elegant default for self-serve secrets is **Forgejo's | |
| 24 | > own Actions-secrets UI**, with Vault demoted to an optional advanced backend. |
docs/design/forgejo-rbac-and-access.md added +185
| @@ -0,0 +1,185 @@ | ||
| 1 | # Design: Forgejo-RBAC app access + Forgejo-UI management | |
| 2 | ||
| 3 | **Pillars 1 & 2.** Make apps private-by-default and gate them on Forgejo repo | |
| 4 | permission at the edge; let users manage secrets and domains from Forgejo's own UI. | |
| 5 | ||
| 6 | ## The problem, precisely | |
| 7 | ||
| 8 | Verified against the live platform and the code: | |
| 9 | ||
| 10 | - **Apps are effectively public.** Login _identifies_ but does not _authorize_: any | |
| 11 | Forgejo account that completes the OAuth flow gets a session (`_app-template/src/auth.ts`), | |
| 12 | and anon users already receive `/` plus any unguarded route (`_app-template/src/server.ts:247`; | |
| 13 | only `/api/me` checks a session). There is **no platform auth proxy** — the per-app | |
| 14 | Ingress routes `/` straight to the app (`mcp/src/server.ts:~2374`). | |
| 15 | - **Permission is never checked.** A user with zero access to the backing repo can fully | |
| 16 | use the app. The app can't even self-identify its repo at runtime (owner/repo live only | |
| 17 | in namespace labels, not in the pod env). | |
| 18 | - **No self-serve management.** Secrets are platform-minted out-of-band; there is no | |
| 19 | "write access → manage" path. | |
| 20 | ||
| 21 | ## The access model — four tiers from one API call | |
| 22 | ||
| 23 | The whole model is a branch on Forgejo's answer to _"what is this user's permission on the | |
| 24 | app's backing repo?"_ — `GET /api/v1/repos/{owner}/{repo}/collaborators/{user}/permission` | |
| 25 | → `{ "permission": "owner"|"admin"|"write"|"read"|"none" }`. (Verified live: non-members | |
| 26 | return `none`, members return their level.) | |
| 27 | ||
| 28 | | Caller | Forgejo permission | What happens | | |
| 29 | | ------------- | ----------------------- | -------------------------------------------------------------------------------------- | | |
| 30 | | Not logged in | — | 302 → Forgejo login (unless the repo is `public`) | | |
| 31 | | Logged in | `none`, repo `internal` | **Allowed** (read-equivalent — any logged-in user, like Forgejo's internal visibility) | | |
| 32 | | Logged in | `none`, repo `private` | **403 → "request access"** page (you're known, but not granted) | | |
| 33 | | Logged in | `read`+ | **Allowed** into the app | | |
| 34 | | Logged in | `write`+ | Allowed **+ the manage surface unlocks** (secrets, domain, settings) | | |
| 35 | ||
| 36 | The three visibility tiers — `private` (collaborators only), `internal` (any logged-in user), | |
| 37 | `public` (anon) — are **Forgejo's own repo-visibility flag**, surfaced on the repo object; the | |
| 38 | app simply inherits the repo's. Default is `private`. Per-app override = flip the repo's | |
| 39 | visibility in Forgejo. (Deriving even this from a Forgejo primitive is the whole point.) | |
| 40 | ||
| 41 | **Default private** falls out for free: app repos are already created `private: true` | |
| 42 | (`mcp/src/server.ts:~1051`; Forgejo `DEFAULT_PRIVATE=private`), so "only collaborators get | |
| 43 | in" is the repo's existing posture, now extended to the running app. Per-app override is a | |
| 44 | single public flag (repo made public/internal, or an app annotation) the owner can flip. | |
| 45 | ||
| 46 | ## The mechanism — one `forwardAuth` middleware, one tiny auth service | |
| 47 | ||
| 48 | Traefik is the single ingress chokepoint (one Ingress per app, `ingressClassName: traefik`). | |
| 49 | A **`forwardAuth` middleware** on the app Ingress template makes Traefik call a platform | |
| 50 | **auth service** for every request _before_ it reaches the app. It **reuses the existing MCP | |
| 51 | OAuth-proxy code** (`mcp/src/server.ts:~4597-4720`: the browser authorize-redirect to Forgejo, | |
| 52 | the identity fetch on callback, and the token-sealing helpers) — but it is not literally the | |
| 53 | same endpoint. The proxy today mints sealed _bearer_ tokens for MCP clients (`claude://`, | |
| 54 | `localhost`), **not browser cookies**; `forwardAuth` is cookie-based. So the genuinely new | |
| 55 | (and small) part is a **session-cookie layer**: set a sealed cookie on the OAuth callback, and | |
| 56 | a `/forward` handler that reads it, runs the permission probe, and returns headers. (The | |
| 57 | per-app better-auth template does cookie sessions today — the proxy takes over that one job, | |
| 58 | which is what enables central revocation.) | |
| 59 | ||
| 60 | yaml | |
| 61 | # rendered onto every app Ingress (gitops overlay) — the only per-app wiring | |
| 62 | apiVersion: traefik.io/v1alpha1 | |
| 63 | kind: Middleware | |
| 64 | metadata: { name: plat-access, namespace: <app-ns> } | |
| 65 | spec: | |
| 66 | forwardAuth: | |
| 67 | address: http://plat-auth.mcp.svc.cluster.local/forward?owner=<owner>=<repo> | |
| 68 | trustForwardHeader: true | |
| 69 | authResponseHeaders: [X-Plat-User, X-Plat-Perm, X-Plat-Manage] | |
| 70 | ||
| 71 | # Ingress gains: traefik.ingress.kubernetes.io/router.middlewares: <app-ns>-plat-access@kubernetescrd | |
| 72 | ||
| 73 | ||
| 74 | The auth service's `/forward` handler is ~30 lines: | |
| 75 | ||
| 76 | ||
| 77 | session = readSealedCookie(req.cookies) # set by this service on the OAuth callback | |
| 78 | if !session: # anon | |
| 79 | if visibility(owner, repo) == public: return 200 | |
| 80 | return 302 -> https://git.$DOMAIN/login/oauth/authorize?... (then back here) | |
| 81 | perm = forgejoPermission(owner, repo, session.user) # cached ~60s (auth-util.ts pattern) | |
| 82 | if perm == "none" and visibility != internal: # internal = any logged-in user is read-equiv | |
| 83 | return 403 (request-access page, links the owner + a request issue) | |
| 84 | return 200 with headers: | |
| 85 | X-Plat-User: <login> | |
| 86 | X-Plat-Perm: read|write|admin | |
| 87 | X-Plat-Manage: "1" if perm in {write, admin, owner} else "0" | |
| 88 | ||
| 89 | ||
| 90 | The owner/repo come from the middleware's query string, stamped per app at render time from | |
| 91 | the **namespace name** — `nsFor(owner,app,env)` = `<owner>--<app>--<env>` (`server.ts:~1839`) | |
| 92 | is the reliable source. (Heads-up for the implementer: the `plat.sh/owner` _label_ is today | |
| 93 | hardcoded to the literal `plat` for every app — `server.ts:~2100`, visible in any rendered | |
| 94 | `manifests.yaml` — so don't read owner from that label; either split the namespace name, or | |
| 95 | land the one-line renderer fix to stamp the real owner first.) | |
| 96 | ||
| 97 | ### What the app does now (much less) | |
| 98 | ||
| 99 | The app stops rolling its own auth gate. It **trusts the injected headers** — `X-Plat-User` | |
| 100 | is a verified Forgejo identity, `X-Plat-Perm` its access level, `X-Plat-Manage` whether to | |
| 101 | show management UI. The template's per-app better-auth + OAuth client goes away (one shared | |
| 102 | proxy instead of one OAuth app per app — simpler, and central session revocation becomes | |
| 103 | possible). `/metrics` and `/openapi.json` get a path rule so they're internal-only. **The | |
| 104 | net app contract is smaller, and every app is secure by construction, not by author | |
| 105 | diligence.** | |
| 106 | ||
| 107 | ### Request-access flow (the `none` case) | |
| 108 | ||
| 109 | The 403 page is honest and actionable: _"You're signed in as @user, but you don't have | |
| 110 | access to {owner}/{repo}. Request access →"_ The button opens a prefilled Forgejo issue on | |
| 111 | the repo (or, for orgs, deep-links the team page). The owner grants by adding the user to a | |
| 112 | team with repo read — a **pure Forgejo operation**, reflected in the permission API | |
| 113 | immediately, no platform state. This is exactly the flow you described, built entirely from | |
| 114 | Forgejo's orgs/teams. | |
| 115 | ||
| 116 | ## Self-serve secrets — Forgejo _is_ the secret store | |
| 117 | ||
| 118 | Forgejo already has a native, write-gated secret store with a UI: **repo/org Settings → | |
| 119 | Actions → Secrets**, exposed at `/api/v1/repos/{o}/{r}/actions/secrets` and | |
| 120 | `/api/v1/orgs/{o}/actions/secrets` (verified live — the platform already sets | |
| 121 | `REGISTRY_TOKEN` this way). So self-serve secrets are not a new product to build: | |
| 122 | ||
| 123 | 1. A user with repo **write** opens _Settings → Actions → Secrets_ and adds `STRIPE_KEY`. | |
| 124 | Forgejo enforces write access — we inherit it. | |
| 125 | 2. A platform **sync** (a small controller, or an MCP reconcile triggered by the repo | |
| 126 | webhook) reads the repo's + owning org's Actions secrets and projects them into a | |
| 127 | sops-sealed `<app>-user-env` Secret in the app namespace. | |
| 128 | 3. The app chart's existing `extraEnvFrom` already forwards that Secret onto the pod | |
| 129 | (`_app-template/charts/app/templates/deployment.yaml`). **No app-template change.** | |
| 130 | ||
| 131 | Naming: Forgejo secret names are uppercase; we sync all of them except a reserved set that | |
| 132 | would collide with platform-injected env (the CNPG `<name>-pg-app` keys, `<name>-s3`, | |
| 133 | `app-auth`) — documented and validated at set-time. Vault stays available as an _optional | |
| 134 | advanced backend_ (dynamic/leased credentials) for the rare app that needs it — but the | |
| 135 | default, elegant path is the Forgejo screen the user already knows. | |
| 136 | ||
| 137 | ## Self-serve domains — the repo is the config | |
| 138 | ||
| 139 | The platform already overloads the repo `website` field as an app's domain selector | |
| 140 | (`resolveAppHost`, `mcp/src/server.ts:~1079`: repo `website` override → org default → platform | |
| 141 | default; one-shot domain claim at `~1063`). Make it self-serve: the user sets the domain on the repo (a | |
| 142 | field or an `actions/variables` entry, Forgejo-UI-native); an MCP `verify_domain` step | |
| 143 | proves ownership with a DNS TXT challenge before granting, then issues a per-domain ACME | |
| 144 | cert (the internal CA can't sign public domains) and writes the existing `domains.yaml` | |
| 145 | registry entry. The single resolved host already feeds Ingress + cert + OAuth redirect | |
| 146 | consistently — re-hosting is a solved path. (Full mechanics: the prior | |
| 147 | `self-serve-custom-domains` proposal, now gated by write permission like secrets.) | |
| 148 | ||
| 149 | ## Why this is the obvious move | |
| 150 | ||
| 151 | - **Zero new identity or permission model** — one Forgejo API call is the entire access | |
| 152 | decision; orgs/teams are the entire grant mechanism. | |
| 153 | - **Apps get smaller, not bigger** — auth leaves the app and becomes one edge middleware; | |
| 154 | every app is private and correct by construction. | |
| 155 | - **Management is a screen the user already uses** — Forgejo's Secrets UI, write-gated for | |
| 156 | free. | |
| 157 | ||
| 158 | ## Rollout (nothing breaks mid-flight) | |
| 159 | ||
| 160 | 1. Land the MCP repo-permission authz first (doc 3) — it's the shared dependency. | |
| 161 | 2. Ship the auth service + middleware behind a per-app `access: forgejo` annotation, | |
| 162 | default-off; opt a few apps in, confirm, then flip the template default to on. | |
| 163 | 3. Migrate apps off per-app better-auth to the injected headers as they redeploy | |
| 164 | (backward-compatible: the proxy can run alongside in-app auth during transition). | |
| 165 | 4. Add the Forgejo-secrets sync; document the reserved names. | |
| 166 | ||
| 167 | ## Decisions already taken (not open) | |
| 168 | ||
| 169 | - **A separate, minimal auth service** (not folded into the MCP pod) — keeps the access | |
| 170 | data-plane off the MCP's broad RBAC and bounds its blast radius. | |
| 171 | - **Three visibility tiers = Forgejo's own** `private` / `internal` / `public` — no new model. | |
| 172 | ||
| 173 | ## Open questions | |
| 174 | ||
| 175 | - **Header trust.** `X-Plat-*` is only safe if apps are reachable _only_ through Traefik. The | |
| 176 | app overlay opens ingress to the traefik ns **and** the `workflows` and `monitoring` | |
| 177 | namespaces (`server.ts:~2202/2226/2251` — the latter is the new observability scrape rule), | |
| 178 | so a compromised pod there could hit the app directly and spoof headers. Fix: either tighten | |
| 179 | those allowances, or have the app **strip `X-Plat-*` on any request that didn't come via | |
| 180 | Traefik** (an `entrypoints`-scoped Traefik header reset is the clean version). | |
| 181 | - **Session revocation TTL** — a shared proxy makes central revocation possible; define the | |
| 182 | cookie TTL + Forgejo-permission re-check interval so a removed collaborator loses app access | |
| 183 | promptly (lean short: ~5 min cached permission, re-probed on miss). | |
| 184 | - **Secret-sync trigger** — repo webhook (`actions secret changed`?) vs. a periodic MCP | |
| 185 | reconcile vs. on-release; pick the cheapest that feels live to the user. |
docs/design/multitenancy-and-agent-identity.md added +199
| @@ -0,0 +1,199 @@ | ||
| 1 | # Design: adversary-safe multi-tenancy + agent identity | |
| 2 | ||
| 3 | **Pillar 3.** Make the platform safe to hand to a stranger: every credential scoped | |
| 4 | to one tenant, every agent run bounded to the user who triggered it, no one able to | |
| 5 | DOS the cluster. | |
| 6 | ||
| 7 | ## The problem, precisely | |
| 8 | ||
| 9 | The isolation _primitives_ are strong, but three things make the platform unsafe to | |
| 10 | share today: | |
| 11 | ||
| 12 | - **One admin god-PAT is the entire control plane.** `PLAT_TOKEN` (minted with | |
| 13 | `["write:admin","read:admin",…]` scopes at `bin/germinate:~301`) is shared by the MCP, the agents dispatcher, and | |
| 14 | the registry. **Agents run with it**, so a prompt-injected issue/PR body can steer a | |
| 15 | run to delete _any_ tenant's app, exec into _any_ database, or read _any_ secret — | |
| 16 | the blast radius of one run is the whole instance. The agent never carries the | |
| 17 | requesting user's identity. | |
| 18 | - **Authz is org _membership_, not repo _permission_.** `authorize()` checks | |
| 19 | `caller.orgs.includes(owner)` (`mcp/src/server.ts:~846`). A **read-only** member of a | |
| 20 | shared org can `delete_app`, `query_db` (read a teammate's database), or | |
| 21 | `rotate_oauth_app` against any app the org owns. The "write gates management, read | |
| 22 | gates use" distinction does not exist. | |
| 23 | - **DOS is wide open.** No storage-_size_ quota (the per-app quota caps PVC _count_ at 5, | |
| 24 | `server.ts:~2144`, but not bytes — on single-node `local-path` one tenant can request | |
| 25 | arbitrarily large volumes and evict everyone), no per-user app cap (loop `create_app` | |
| 26 | until scheduling is exhausted), no per-tenant CI ceiling (one push storm starves all | |
| 27 | builds on the 6 shared dind runners). The webhook receiver (`POST /hook`, | |
| 28 | `dispatcher.ts:~2704`) verifies **no signature** — anything that reaches the port can | |
| 29 | forge `agent-work` events. | |
| 30 | ||
| 31 | ## What already holds (validated locally on k3d, so we build on bedrock) | |
| 32 | ||
| 33 | | Experiment | Result | | |
| 34 | | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | | |
| 35 | | **ResourceQuota** rejects an over-limit pod | ✅ `exceeded quota … forbidden` | | |
| 36 | | **App pod's default ServiceAccount** hitting the kube API | ✅ `403 Forbidden` — no kube authority by default | | |
| 37 | | **A per-app namespaced read-only Role** (the agent-context idea) | ✅ `get pods/logs` in its own ns = **yes**; another tenant, `delete`, `secrets`, cluster-wide = **no** | | |
| 38 | | **NetworkPolicy default-deny** (kube-router) blocks cross-tenant | ✅ enforced (the platform already relies on it) | | |
| 39 | | **PodSecurity `restricted`** enforced per app namespace | ✅ non-root / no-caps / read-only-rootfs by the API server | | |
| 40 | ||
| 41 | So namespace isolation, default-deny networking, pod hardening, and quotas all _work_. | |
| 42 | The gaps are specific and each closes with a small, well-scoped change. | |
| 43 | ||
| 44 | ## The fix — five changes, each small, each default-safe | |
| 45 | ||
| 46 | ### 1. Repo-permission authz (the keystone — do this first) | |
| 47 | ||
| 48 | Replace the org-membership gate with a **delegated permission probe**. The MCP already | |
| 49 | has the caller's own Forgejo token (`caller.forgejoAuth`); one call — | |
| 50 | `GET /repos/{owner}/{repo}/collaborators/{caller}/permission` — returns Forgejo's fully | |
| 51 | resolved answer (org base perm + team perm + collaborator perm). Tag each tool with the | |
| 52 | permission it needs: | |
| 53 | ||
| 54 | ||
| 55 | read → read tools (get_app_status, tail_logs, query_db read-only, get_build) | |
| 56 | write → write tools (set_secret, set_domain, retry_build, release_app) | |
| 57 | admin → admin tools (delete_app, rotate_oauth_app) | |
| 58 | ||
| 59 | ||
| 60 | `authorize()` (`server.ts:~818`) keeps the cheap org/owner check as a pre-filter, then | |
| 61 | makes the permission probe the authoritative gate. This single change closes the | |
| 62 | headline hole and is the dependency for the access model in doc 2. | |
| 63 | ||
| 64 | ### 2. The agent _is_ the user (retire the god-PAT for runs) | |
| 65 | ||
| 66 | A run is triggered by a specific user (the issue/PR author or labeler). Hand the run a | |
| 67 | token that acts **as that user**, instead of the admin PAT. The least-obvious step is _how_: | |
| 68 | a webhook-triggered run has no interactive OAuth session, so we can't reuse the proxy's | |
| 69 | user-token directly. Two real options — (a) **Forgejo admin sudo**: the dispatcher mints a | |
| 70 | short-lived PAT for the author, or sets the `Sudo: <author>` header on delegated API calls | |
| 71 | (Forgejo impersonation, available to admin), scoped to the run; (b) a **per-user scoped PAT** | |
| 72 | the platform manages. Either way the run gets the user's authority, not admin's. Consequences, | |
| 73 | all good: | |
| 74 | ||
| 75 | - The run inherits the user's Forgejo permission. It can only touch repos the user can, | |
| 76 | and (via change 1) only manage apps the user has write on. | |
| 77 | - It falls under the MCP's existing per-caller rate-limit and deny-list (admins are | |
| 78 | exempt today). A runaway or hostile run is automatically bounded and killable. | |
| 79 | - Per-run scope is _enforced_, not conventional: today the runner clones one repo but | |
| 80 | embeds an all-powerful token (`runner.ts:~432`); the scoped token can't reach beyond | |
| 81 | the user's authority. | |
| 82 | ||
| 83 | The dispatcher's own bookkeeping (registering webhooks, sweeping) keeps a _narrow_ | |
| 84 | platform identity; only the **agent subprocess** drops to the user's scope. | |
| 85 | ||
| 86 | ### 3. Scoped kube context for the agent (your idea, validated) | |
| 87 | ||
| 88 | When a run is for a repo the user has **write** on, give its agent real cluster context | |
| 89 | with a tiny blast radius: a **per-app ServiceAccount + a namespaced read-only Role**, and | |
| 90 | a **short-lived token** minted per run (`TokenRequest` / `kubectl create token --duration`). | |
| 91 | ||
| 92 | yaml | |
| 93 | # rendered once per app into its namespace (gitops overlay) | |
| 94 | kind: ServiceAccount | |
| 95 | metadata: { name: agent-ctx, namespace: <owner>--<app>--prod } | |
| 96 | ||
| 97 | kind: Role # read-only, app-namespace-scoped | |
| 98 | rules: | |
| 99 | - { | |
| 100 | apiGroups: [""], | |
| 101 | resources: [pods, pods/log, events, services], | |
| 102 | verbs: [get, list, watch], | |
| 103 | } | |
| 104 | - { | |
| 105 | apiGroups: [apps], | |
| 106 | resources: [deployments, replicasets], | |
| 107 | verbs: [get, list, watch], | |
| 108 | } | |
| 109 | # NOTE: no secrets, no mutate, no other namespace — validated above. | |
| 110 | ||
| 111 | ||
| 112 | The agent gets exactly the "full context it needs without breaking things" — it can read | |
| 113 | its app's pods, logs, and rollout state — and **nothing else**: not another tenant, not | |
| 114 | secrets, not the cluster API, and it can't mutate. Token TTL = the run's lifetime. This is | |
| 115 | the empirically-proven blast-radius bound from the table above. | |
| 116 | ||
| 117 | ### 4. DOS bounds (small additions to machinery that already exists) | |
| 118 | ||
| 119 | The per-app overlay already emits `ResourceQuota` + `LimitRange` (`server.ts:~2093`), and a | |
| 120 | comment there already plans a tier annotation. Close the gaps: | |
| 121 | ||
| 122 | - **Storage _size_ quota** (one line): add `requests.storage` to the per-app quota — the | |
| 123 | PVC _count_ cap (`persistentvolumeclaims: 5`) already exists at `server.ts:~2144`; what's | |
| 124 | missing is the byte cap, the real disk-DOS vector on single-node `local-path`. | |
| 125 | - **Per-user app ceiling**: `create_app` checks the caller's current app count against a | |
| 126 | tier limit before provisioning — caps the namespace/scheduling-DOS vector. | |
| 127 | - **Per-org CI concurrency** (the costlier item — flag it): the runner pool is a single KEDA | |
| 128 | `ScaledJob` (max 6) driven by one _global_ queued-job count (`runner-scaledjob.yaml`). A | |
| 129 | per-tenant ceiling can't come from editing that one query — it needs either per-owner | |
| 130 | ScaledJobs or a gate before enqueue. Heavier than the others; sequence it last. | |
| 131 | - **Tiers**: free / paid / trusted as a namespace annotation parameterizing the quota — | |
| 132 | the planned-for knob, now real. | |
| 133 | ||
| 134 | ### 5. Authenticate the webhook bus | |
| 135 | ||
| 136 | The bus is a **single admin _system_ hook** (`ensureWebhook` → `createAdminHook`, | |
| 137 | `dispatcher.ts:~2593`) that already fires for every repo/org event and is routed in-process | |
| 138 | by type — so new repos need no per-repo registration. Forgejo's hook config supports a | |
| 139 | `secret` / `authorization_header`; set one shared secret on that system hook and verify the | |
| 140 | HMAC in `POST /hook` (`dispatcher.ts:~2704`). Closes event forgery — the precondition for | |
| 141 | trusting webhooks as the agentic event bus (the growth loop). (If the growth loop later adds | |
| 142 | per-org hooks, each carries its own secret through the same code path.) | |
| 143 | ||
| 144 | ## The model in one sentence | |
| 145 | ||
| 146 | **An agent run is "the user, scoped":** its Forgejo authority is the triggering user's | |
| 147 | (delegated), its kube authority is a per-app read-only Role (short-lived), and its blast | |
| 148 | radius is the user's own corner of the platform. A compromised or prompt-injected run can, | |
| 149 | at most, do what that user could already do to their own app — which is exactly the | |
| 150 | guarantee that lets you hand the platform to an adversary. | |
| 151 | ||
| 152 | ## Can a user reliably deploy their own services? (and the natural ceiling) | |
| 153 | ||
| 154 | Yes, _within their tier_: a user/org deploys apps bounded by their ResourceQuota, app | |
| 155 | ceiling, and CI concurrency — so "deploy whatever you want" is safe because "whatever" is | |
| 156 | capped and isolated. The same bounds answer "could a user DOS the system?": today yes; after | |
| 157 | change 4, no — the worst a tenant can do is exhaust _their own_ quota. External integrations | |
| 158 | ("get this up and integrate to app X", Stripe) ride the same rails: the agent runs as the | |
| 159 | user, provisions within the user's tier, and wires secrets the user set in Forgejo. | |
| 160 | ||
| 161 | **A tenant's own gitops subtree** (the "could orgs run their own GitOps reliably?" thread) is | |
| 162 | the natural next step, not a separate system: a Flux `Kustomization` scoped to the tenant's | |
| 163 | path under the shared repo (or a tenant repo), reconciled with `serviceAccountName` + | |
| 164 | `decryption` bound to _their_ namespace and quota — so their declarative state can't escape | |
| 165 | their tier. Worth a follow-up design; the bounds here are its prerequisite. | |
| 166 | ||
| 167 | ## Notifications — the human-in-the-loop channel | |
| 168 | ||
| 169 | The vision leans on notifications to "alert me when high-level decisions need to be made." Make | |
| 170 | that concrete: the MCP/dispatcher raises a **Forgejo notification + issue** (as the platform | |
| 171 | identity, so it lights the user's notification bell — `GET /notifications`) on exactly three | |
| 172 | triggers, all of which already exist as decision points in these docs: (1) an **access request** | |
| 173 | from the `none` page (doc 2); (2) a **bound/threshold crossing** — a tenant nearing their quota | |
| 174 | or an unusual cost/CI spike (change 4); (3) an **admin-tool action** — a `delete_app`/`rotate` | |
| 175 | that warrants a heads-up (change 1). No new channel: it is Forgejo's own notifications, written | |
| 176 | the same way agents already file issues. | |
| 177 | ||
| 178 | ## Rollout | |
| 179 | ||
| 180 | 1. **Change 1 (repo-permission authz)** — behind a flag, shadow-log old-vs-new decisions | |
| 181 | first, then enforce. Unblocks doc 2. | |
| 182 | 2. **Change 5 (webhook HMAC)** — independent, low-risk, do anytime. | |
| 183 | 3. **Change 4 (DOS bounds)** — additive to the overlay; default tiers generous, tighten later. | |
| 184 | 4. **Changes 2 + 3 (agent-as-user + scoped kube)** — the deepest; land behind a flag on | |
| 185 | the agents side, validate on one repo (mitosis/gitops via `agent-work`, already enabled), | |
| 186 | then default on. Keep the dispatcher's narrow platform identity for bookkeeping. | |
| 187 | ||
| 188 | ## Open questions | |
| 189 | ||
| 190 | - Delegated token lifetime vs. long runs — refresh strategy so a 30-min run doesn't outlive | |
| 191 | its token, without widening scope. | |
| 192 | - Where the kube token is minted — MCP (has cluster creds) issuing per-run via `TokenRequest` | |
| 193 | is cleanest; the agents pod then never holds standing cluster power. | |
| 194 | - Tier definitions + who sets them (admin? per-org billing later?). Start with one generous | |
| 195 | default tier; make it data, not code. | |
| 196 | - Whether `query_db`/`tail_logs` should require **write** (they can expose secret-bearing | |
| 197 | data) rather than read — lean write for anything that can read another user's data. | |
| 198 | - CNPG Postgres egress currently allows a `/12` that can include the k3d apiserver — tighten | |
| 199 | to the in-namespace DB only (defense in depth beyond the app pod). |
docs/vision/enterprise-platform.md added +123
| @@ -0,0 +1,123 @@ | ||
| 1 | # The Enterprise Platform: Forgejo as the Control Plane | |
| 2 | ||
| 3 | > The platform is already great at one thing: you have an idea on your phone, you | |
| 4 | > open Forgejo, you click, and an agent builds and ships an app. The next leap is | |
| 5 | > to make that same Forgejo-driven loop govern the _running_ world — who can see an | |
| 6 | > app, who can manage it, what an agent is allowed to touch — so the platform is | |
| 7 | > **private by default, safe to hand to a stranger, and able to grow itself.** | |
| 8 | ||
| 9 | ## The one idea everything follows from | |
| 10 | ||
| 11 | **We do not need to build a control plane. Forgejo _is_ the control plane.** | |
| 12 | ||
| 13 | Identity, SSO, org/team RBAC, per-repo permission levels, a secret store with a UI, | |
| 14 | webhooks, notifications, and an interface people genuinely enjoy — it is all already | |
| 15 | there, and the platform already runs on it. Every enterprise capability we want is a | |
| 16 | matter of _reading the Forgejo primitive we are not yet reading_ and _deriving the | |
| 17 | runtime from it_ — not inventing a parallel system. | |
| 18 | ||
| 19 | That single discipline — **derive every capability from Forgejo RBAC** — is what keeps | |
| 20 | the platform cohesive as it grows, and what lets you hand it to people (or an | |
| 21 | adversary) and trust that nothing of yours, or another user's, breaks. | |
| 22 | ||
| 23 | ## Where we are vs. where this takes us | |
| 24 | ||
| 25 | | | Today (great at building) | Next (great at running) | | |
| 26 | | ------------------ | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | | |
| 27 | | **App access** | Any logged-in user gets into any app; anon sees the homepage and any unguarded route | Default **private**; gated at the edge by repo permission — anon → login, no-access → _request access_, read → use, write → manage | | |
| 28 | | **Secrets** | Platform-minted only; users can't set their own | The user sets them in **Forgejo's own Secrets UI**; the platform syncs them in. Write-gated by Forgejo | | |
| 29 | | **Domains** | Admin hand-edits gitops | Set on the repo in Forgejo; the platform verifies ownership and issues TLS | | |
| 30 | | **Agent identity** | One **admin god-PAT** for every run — a prompt-injected agent can touch every tenant | The agent acts **as the requesting user**; on apps they can manage, a **scoped, ns-bounded, read-only kube token** for context | | |
| 31 | | **Multi-tenancy** | Strong network isolation, but a user can DOS the cluster and a read-only org member can delete another's app | Per-tenant blast-radius bounds; **repo-permission**, not org-membership, decides who can manage what | | |
| 32 | | **Growth** | You drive every action | Forgejo **webhooks are an agentic event bus**; the platform builds, alerts, and extends itself | | |
| 33 | ||
| 34 | The first column is true _today_ and is the foundation. None of this throws it away — | |
| 35 | it tightens the same Forgejo-driven loop around the parts that are currently open. | |
| 36 | ||
| 37 | ## Three pillars (each its own design doc) | |
| 38 | ||
| 39 | **1 — Access is Forgejo permission, enforced at the edge.** | |
| 40 | [](../design/forgejo-rbac-and-access.md). One Traefik | |
| 41 | `forwardAuth` middleware in front of every app calls a tiny platform auth service that does | |
| 42 | Forgejo SSO and **one** API call — | |
| 43 | `GET /repos/{owner}/{repo}/collaborators/{user}/permission` — against the app's backing repo | |
| 44 | (resolved from the app's namespace name). The four-tier outcome is the table above; the point | |
| 45 | is the mechanism: **no per-app auth code, and the repo's access list _is_ the app's.** | |
| 46 | ||
| 47 | **2 — Management lives in Forgejo's own UI.** Same doc. Forgejo already has a write-gated | |
| 48 | **Actions secrets** store (repo + org) with a UI — the platform uses it for `REGISTRY_TOKEN` | |
| 49 | today. So self-serve secrets aren't a new product: the user sets `STRIPE_KEY` in _Settings → | |
| 50 | Actions → Secrets_, the platform syncs it into the app's env, and Forgejo enforces write-access | |
| 51 | for free. Domains ride the repo's fields the same way. _Could management live in the Forgejo | |
| 52 | UI? It already does — we just point apps at it._ | |
| 53 | ||
| 54 | **3 — The runtime is safe to hand to an adversary.** | |
| 55 | [](../design/multitenancy-and-agent-identity.md). | |
| 56 | Retire the single admin PAT: a run acts **as its triggering user**, and — on apps they can | |
| 57 | manage — gets a **per-app, namespaced, read-only kube token** (validated: pods/logs in _its_ | |
| 58 | namespace only; no other tenant, no secrets, no cluster API). Authz moves to **repo | |
| 59 | permission**, not org membership, and the cluster gets the few missing DOS bounds. The | |
| 60 | guarantee in one line: _an agent run is the user, scoped — its blast radius is the user's own | |
| 61 | corner._ | |
| 62 | ||
| 63 | ## The growth loop — how the platform builds itself | |
| 64 | ||
| 65 | Every Forgejo event is an attach point for an agentic process. This is the engine that | |
| 66 | turns the platform from a tool you operate into an ecosystem that compounds: | |
| 67 | ||
| 68 | - **Issue labeled `agent-work`** → an agent builds it. (Live today — and now extended to | |
| 69 | the platform's _own_ repos, so the platform develops itself: `plat/agents#2`.) | |
| 70 | - **Org created, with a description** → the platform proposes and builds the org's first | |
| 71 | software. "Landscaping co." → a marketing site, a scheduling app, a money tracker — | |
| 72 | each a normal app the agent ships, that you approve. | |
| 73 | - **"Get this up and integrate it to app X"** → seed the same build loop with **Forgejo's | |
| 74 | native repo-migration** (it clones an external repo straight in); an agent ports it onto the | |
| 75 | app-template and ships it as a normal app, then wires it to app X's data with a self-serve | |
| 76 | secret. Stripe onto anything is just a secret + a few routes. (The auto-port mechanics are | |
| 77 | their own proposal — `repo-migration-auto-port`.) | |
| 78 | - **Notifications** are the human-in-the-loop channel: the platform raises a Forgejo | |
| 79 | notification (and issue) when a _high-level decision_ needs you — a cost threshold, an | |
| 80 | access request, a risky change — so the ecosystem grows on its own but pauses for the | |
| 81 | calls only you should make. | |
| 82 | ||
| 83 | You stay in the loop where it matters and out of it where it doesn't. The platform grows; | |
| 84 | your mental model stays cohesive, because it is all still _Forgejo, orgs, repos, and | |
| 85 | permissions_ — the same model you already hold. | |
| 86 | ||
| 87 | ## Principles (the test every change must pass) | |
| 88 | ||
| 89 | 1. **Derive from Forgejo RBAC.** No new identity or permission model. If Forgejo can | |
| 90 | answer it, ask Forgejo. | |
| 91 | 2. **Private and least-privilege by default.** Zero data to anon; minimum permission to | |
| 92 | everyone (and every agent); explicit, per-app opt-out — never opt-in-to-safety. | |
| 93 | 3. **Small blast radius.** Every credential is scoped to one tenant and short-lived. An | |
| 94 | adversary with access, or a prompt-injected agent, can damage at most their own corner. | |
| 95 | 4. **Forgejo UI first.** If a capability can be a Forgejo repo/org setting, it is one | |
| 96 | before it is a bespoke screen. | |
| 97 | 5. **Humans approve high-level decisions, agents do the rest.** Notifications carry the | |
| 98 | former; webhooks drive the latter. | |
| 99 | ||
| 100 | ## Roadmap — substrate to hand to the platform | |
| 101 | ||
| 102 | These are written to become `agent-work` issues the platform can build _on itself_. Each | |
| 103 | is small, independently shippable, and lands behind a default-off flag so nothing breaks | |
| 104 | mid-rollout. (P = pillar.) | |
| 105 | ||
| 106 | 1. **(P3) Repo-permission authz in MCP** — swap the org-membership check for a delegated | |
| 107 | `…/permission` probe; read→read tools, write→write/manage tools. _Closes the biggest | |
| 108 | hole; unblocks everything else._ | |
| 109 | 2. **(P1) The edge auth service + `forwardAuth` middleware** — one small service, one | |
| 110 | Ingress-template annotation. Apps go private with zero app-code change. | |
| 111 | 3. **(P1) The request-access page + Forgejo team-add deep link** — turn `none` into a | |
| 112 | one-click "ask the owner." | |
| 113 | 4. **(P2) Forgejo-secrets → app env sync** — a controller (or MCP reconcile) projecting | |
| 114 | repo/org Actions secrets into the app's Secret; write-gated by Forgejo. | |
| 115 | 5. **(P3) Per-user delegated agent identity + per-app scoped kube token** — retire the | |
| 116 | god-PAT for runs; give agents real, bounded context. | |
| 117 | 6. **(P3) DOS bounds** — storage quota, per-user app ceiling, per-org CI concurrency, | |
| 118 | HMAC on the webhook receiver. | |
| 119 | 7. **(P2) Self-serve domains** — repo-field-driven, with DNS ownership verification + ACME. | |
| 120 | 8. **(Growth) The org-created and external-import webhooks** — the build-it-for-me loop. | |
| 121 | ||
| 122 | Read the two design docs for the concrete mechanics. They are deliberately obvious: each | |
| 123 | is a small amount of new code wrapped around a Forgejo primitive that already exists. |