Public: anyone can read this; nobody can change it.

docs: enterprise-platform vision — Forgejo as the control plane #10

opened this view on origin

Check · Review · Land · Live — No review yet. Next: reviewer, no review yet.

Merges on the origin

docs/enterprise-platform → main 1 commit

  • Merge on the origin. This repository mirrors open-platform/mitosis, which is truth for it. Merge there and the next sync brings back the merge commit it actually made. Open on the origin

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.

Review

Sign in to review this change.
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`](./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`](./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`](./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=<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`](../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`](../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.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs