Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .nebari-operator-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v0.1.0-alpha.20
v0.2.0
19 changes: 14 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ Keycloak auth. See [`docs/src/content/docs/architecture.md`](docs/src/content/do

| Path | What | Key files |
|---|---|---|
| `operator/` | Go operator (controller-runtime) | `api/v1alpha1/app_types.go` (the CRD), `internal/controller/app_controller.go` (reconcile pipeline), `internal/controller/workload.go` (pod spec), `internal/controller/authz.go` (the authz sidecar for Apps with groups, that can idle, or that run an image), `internal/controller/idle.go` (scale to zero: activity polling, the Idle phase, the activator ReferenceGrant), `internal/controller/activator.go` (the page idle Apps serve while they wake), `internal/controller/sources.go` (source volumes and init containers), `internal/controller/imageref.go` (image references and the registry allowlist for `source.type: image`), `internal/controller/routing.go` (NebariApp) |
| `authz/` | Go sidecar: verifies the forwarded access token and enforces `access.groups` (403 otherwise; Apps restrict access by group only); in passthrough mode (unrestricted Apps that can idle) it only proxies. Either way it reports activity for the idle check | `main.go` (env config), `internal/gate/verify.go` (token checks), `internal/gate/proxy.go` (proxy + probes), `internal/gate/activity.go` (`/activity`) |
| `operator/` | Go operator (controller-runtime) | `api/v1alpha1/app_types.go` (the CRD), `internal/controller/app_controller.go` (reconcile pipeline), `internal/controller/workload.go` (pod spec), `internal/controller/authz.go` (the authz sidecar for Apps that can idle or run an image), `internal/controller/idle.go` (scale to zero: activity polling, the Idle phase, the activator ReferenceGrant), `internal/controller/activator.go` (the page idle Apps serve while they wake), `internal/controller/sources.go` (source volumes and init containers), `internal/controller/imageref.go` (image references and the registry allowlist for `source.type: image`), `internal/controller/routing.go` (NebariApp) |
| `authz/` | Go sidecar in front of Apps that can idle and image Apps: proxies every request and reports activity for the idle check. It checks no tokens: `access.groups` are enforced at the gateway (NebariApp `auth.groups`), and it refuses to start if given group settings | `main.go` (env config), `internal/gate/config.go` (env), `internal/gate/proxy.go` (proxy + probes), `internal/gate/activity.go` (`/activity`) |
| `api/` | FastAPI REST API over App CRs | `src/nebari_apps_api/main.py` (routes), `auth.py` (JWT + owner/admin checks), `models.py` (request/response models), `cr.py` (model ↔ CR), `images.py` (image allowlist, mirrors `imageref.go`), `k8s.py` (store) |
| `mcp/` | FastMCP server, agent tools, resources and prompts at `/mcp` | `src/nebari_apps_mcp/server.py` (tools, `nebari-apps://` resources, prompts), `auth.py` (MCP OAuth: protected resource metadata, JWT verification), `untrusted.py` (fencing app output) |
| `ui/` | React + Vite UI on the Nebari design registry | `src/pages/`, `src/components/` (`app-form/`: state, sections), `src/hooks/use-app-actions.ts`, `src/lib/api.ts`, `src/lib/types.ts` (over the generated `crd.gen.ts`) |
Expand Down Expand Up @@ -63,20 +63,29 @@ helm lint charts/nebari-apps --set clusterDomain=nebari.example.com
hand to `api/src/nebari_apps_api/models.py` (`api/tests/test_crd_conformance.py` fails until it
is) and `cr.py`, the MCP `launch_app` arguments, `skill/new-nebari-app/references/manifest.md`
and `docs/src/content/docs/app-crd-reference.md`.
- **The NebariApp shape is a pinned contract** (nebari-operator `v0.1.0-alpha.20`, pinned once in
- **The NebariApp shape is a pinned contract** (nebari-operator `v0.2.0`, pinned once in
`.nebari-operator-version` and mirrored by `NebariOperatorContract` in `routing.go`), guarded by
`operator/internal/controller/nebariapp_contract_test.go`. Do not change emitted fields
without updating that test and the "NebariApp fields the chart emits" table in
`docs/src/content/docs/configuration.md`.
- **The gateway enforces `access.groups`, not the pod.** A restricted App's NebariApp carries
`auth.groups` with `enforceAtGateway: true`, and nebari-operator's SecurityPolicy returns
403 to non-members. The authz sidecar is passthrough only (activity for idling, the front
port for image Apps); never add token or group checks back to it, and never set
`forwardAccessToken` (app pods do not get the user's token). Groups are full Keycloak paths
matched exactly (`finance` == `/finance`; `/finance` does not admit `/finance/analysts`),
normalized like nebari-operator's `groups.NormalizePath` in `authz.go` (`groupPath`),
`api/src/nebari_apps_api/groups.py` and `ui/src/lib/access.ts` (`groupPath`); change them
together. apps-api matches `api.adminGroups` and `access.managers.groups` the same way.
- **Every per-App API route authorizes through `authorized_app`** (owner, a manager listed
in `spec.access.managers`, or an `api.adminGroups` member; only the owner and admins may
change the manager list). `api/tests/test_authz.py` enumerates the routes and fails when a
new one has no authorization case; add it there.
- **App pods are untrusted user code.** Keep them non-root, capability-free, without a
ServiceAccount token, and with resource limits. User input reaches init containers through
environment variables, never shell interpolation. The `app` container stays first and
keeps its name (logs default to it); restricted Apps, Apps that can idle and image Apps add the
`authz` sidecar after it. The operator reads idle Apps' activity from the pod, so treat that answer as
keeps its name (logs default to it); Apps that can idle and image Apps add the `authz`
sidecar after it. The operator reads idle Apps' activity from the pod, so treat that answer as
untrusted too (bounded, clamped, and a failed check never idles the App). MCP tools and resources return app-controlled text
(logs, files, events) only through the fences in `mcp/src/nebari_apps_mcp/untrusted.py`, and
resources read through the same apps-api calls as the matching tools, so they share the API's
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ chart; see the `chart` job for the arguments).
| Component | Directory | Checks |
|---|---|---|
| Operator (Go) | `operator/` | `go vet ./...`, `go build ./...`, `go test ./... -race` (CI also enforces an `internal/` coverage floor, in `.github/workflows/ci.yaml`), `golangci-lint run` (config in `operator/.golangci.yml`), and the envtest suite (below) |
| Authz sidecar (Go) | `authz/` | `go mod tidy -diff`, `go vet ./...`, `go build ./...`, `golangci-lint run` (config in `authz/.golangci.yml`), `go test ./... -race` with an `internal/` coverage floor (in `.github/workflows/ci.yaml`); the tests mint tokens against a fake JWKS |
| Authz sidecar (Go) | `authz/` | `go mod tidy -diff`, `go vet ./...`, `go build ./...`, `golangci-lint run` (config in `authz/.golangci.yml`), `go test ./... -race` with an `internal/` coverage floor (in `.github/workflows/ci.yaml`) |
| CRD / deepcopy | `operator/` | Regenerate with `controller-gen` v0.21.0 (commands below) and commit the result; CI fails on drift. |
| apps-api | `api/` | `uv lock --check`, `uv sync --frozen --extra dev`, `uv run --frozen --no-sync python -m pytest -q --cov` (fails under `fail_under` in `pyproject.toml`), then `ruff check .`, `ruff format --check .` and `mypy` (strict) through `uv run --frozen --no-sync` |
| apps-mcp | `mcp/` | Same as apps-api, plus `uv pip install --no-deps -e ../api` before the tests (they drive the real API in process). |
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ start again on the next request
| apps-api | REST API for CRUD, status, logs, events, analytics and zip/`.html` upload; writes `App` resources (and each App's write-only Secret) for the UI and the MCP server. | `apps-api` |
| apps-ui | The apps list as the home page (with a Needs attention banner, a Mine filter and Open links), app detail with logs and events, the launch form, and a Settings page (the UI and API versions, your groups, the cluster's settings, and how to connect an agent). | `apps-ui` |
| apps-mcp | MCP server at `/mcp` that lets coding agents launch and manage apps (tools), attach an app's spec, status, logs and events as context (resources), and follow the triage, launch and access-review workflows (prompts), with Keycloak device-flow login. | `apps-mcp` |
| apps-authz | Sidecar in the pods of Apps that list `access.groups` (returns `403` to everyone else) or that can idle (records when the app last served a request). | `apps-authz` |
| apps-authz | Sidecar in the pods of Apps that can idle (records when the app last served a request) and of image Apps (gives them the one front port). It checks no tokens: `access.groups` are enforced at the gateway. | `apps-authz` |

Images are published to `ghcr.io/nebari-dev/apps-pack/<image>` and
`quay.io/nebari/apps-pack-<image>`. The UI signs in with keycloak-js and the API validates its
Expand All @@ -49,7 +49,7 @@ be rebranded from chart values without rebuilding the image

## Quick start

Prerequisites: a Nebari cluster running nebari-operator `v0.1.0-alpha.20` or later (the version
Prerequisites: a Nebari cluster running nebari-operator `v0.2.0` or later (the version
pinned in [`.nebari-operator-version`](.nebari-operator-version)), Helm 3.8+, and `kubectl` with
cluster-admin. The full list is in
[Getting started](https://packs.nebari.dev/apps-pack/getting-started/).
Expand Down Expand Up @@ -104,8 +104,10 @@ The user guide is at [packs.nebari.dev/apps-pack](https://packs.nebari.dev/apps-

## Known limitations

- `access.groups` is enforced in the app pod by the apps-authz sidecar, not
at the gateway (Apps restrict access by Keycloak group only); group changes apply when the user's token refreshes (5 minutes by default).
- Apps restrict access by Keycloak group only, enforced at the gateway (nebari-operator
`v0.2.0` or later). Groups are full paths matched exactly (`finance` is `/finance`, which
does not include `/finance/analysts`); group changes apply when the user's token refreshes
(5 minutes by default).
- Users see and manage the Apps they launched or are listed as managers of
(`access.managers`); members of `api.adminGroups` manage all of them. Ownership cannot be
transferred yet.
Expand Down
15 changes: 9 additions & 6 deletions api/src/nebari_apps_api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

from .config import settings
from .groups import group_paths
from .models import Role

_bearer = HTTPBearer(auto_error=False)
Expand All @@ -37,7 +38,7 @@ def is_admin(user: User) -> bool:
"""
if not settings.auth_enabled:
return True
return any(g.lstrip("/") in settings.admin_groups for g in user.groups)
return bool(group_paths(user.groups) & group_paths(settings.admin_groups))


def app_owner(cr: dict[str, Any]) -> str:
Expand All @@ -60,15 +61,14 @@ def app_managers(cr: dict[str, Any]) -> dict[str, list[str]]:


def is_manager(user: User, cr: dict[str, Any]) -> bool:
"""Listed in spec.access.managers by username (case-insensitive) or through a group in the caller's verified token (a leading
"/" ignored on both sides, like api.adminGroups).
"""Listed in spec.access.managers by username (case-insensitive) or through a group in the caller's verified token,
matched as full group paths like api.adminGroups ("web-team" is "/web-team"; a subgroup does not count).
"""
managers = app_managers(cr)
name = user.username.lower()
if name and any(u.lower() == name for u in managers["users"]):
return True
groups = {g.lstrip("/") for g in user.groups}
return any(g.lstrip("/") in groups for g in managers["groups"])
return bool(group_paths(user.groups) & group_paths(managers["groups"]))


def app_role(user: User, cr: dict[str, Any]) -> Role:
Expand Down Expand Up @@ -133,7 +133,10 @@ def validate(self, token: str) -> User:
return User(
username=claims.get("preferred_username", claims.get("sub", "")),
email=claims.get("email", ""),
groups=[g.lstrip("/") for g in claims.get("groups", [])],
# Full group paths ("/finance"), as nebari-operator v0.2.0+ makes
# Keycloak emit them; a client still sending bare names is read as
# the top-level group of that name.
groups=sorted(group_paths(str(g) for g in claims.get("groups") or [])),
)


Expand Down
7 changes: 4 additions & 3 deletions api/src/nebari_apps_api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,12 @@ class Settings:
)

# Keycloak groups whose members may see and manage every App. Everyone
# else sees and manages only the Apps they own (spec.owner). Group names
# are compared without a leading "/" (Keycloak full-path groups).
# else sees and manages only the Apps they own (spec.owner). Entries are
# full group paths ("admin" is "/admin"; a subgroup such as "/team/admin"
# does not count), compared through groups.group_path.
admin_groups: list[str] = field(
default_factory=lambda: [
g.strip().lstrip("/") for g in os.environ.get("APPS_ADMIN_GROUPS", "admin").split(",") if g.strip()
g.strip() for g in os.environ.get("APPS_ADMIN_GROUPS", "admin").split(",") if g.strip()
]
)

Expand Down
28 changes: 28 additions & 0 deletions api/src/nebari_apps_api/groups.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Keycloak group paths, compared the way nebari-operator matches them.

nebari-operator (v0.2.0+) enforces an App's access.groups at the gateway
against the token's groups claim, which carries full group paths such as
"/finance" or "/parent/child". An entry is the full path of one group: a bare
name is shorthand for the top-level group ("finance" is "/finance"), and a
parent does not include its subgroups ("/finance" does not admit members of
"/finance/analysts" only). apps-api uses the same rule for api.adminGroups
and access.managers.groups, so one group means the same thing everywhere.
"""

from __future__ import annotations

from collections.abc import Iterable


def group_path(entry: str) -> str:
"""The canonical full path of a group entry, as nebari-operator's
groups.NormalizePath computes it: a leading "/", no trailing "/" and no
empty segments. Returns "" for an entry that names no group ("", "/").
"""
segments = [s for s in entry.strip().split("/") if s]
return "/" + "/".join(segments) if segments else ""


def group_paths(entries: Iterable[str]) -> set[str]:
"""The canonical paths of entries, without the ones that name no group."""
return {p for p in (group_path(e) for e in entries) if p}
21 changes: 13 additions & 8 deletions api/src/nebari_apps_api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from . import cr as crmod
from .auth import User, app_managers, app_role, can_access, can_change_managers, current_user, is_admin
from .config import settings
from .groups import group_path
from .images import check_image, parse_allowlist
from .k8s import READY_TIMEOUT, AppStore, ConflictError, KubernetesAppStore, NotFoundError
from .limits import BodySizeLimitMiddleware
Expand Down Expand Up @@ -563,19 +564,23 @@ def entry(ns: str, name: str) -> dict[str, int]:


def _check_access(access: Any) -> None:
"""Groups reach the authz sidecar as a comma-separated list, so an entry
may not be blank or contain a comma (the operator refuses such an App;
this says so up front).
"""Each group entry must name a Keycloak group. nebari-operator matches
entries as full group paths ("finance" is "/finance") and denies every
request when all of an App's entries name nothing (such as "/"); the
apps-operator refuses such an App, and this says so up front.
"""
if access is None:
return
for entry in access.groups:
if not entry.strip() or "," in entry:
raise HTTPException(422, "access.groups: entries must be non-empty and must not contain ','")
if not group_path(entry):
raise HTTPException(422, "access.groups: entries must name a group, such as finance or /parent/child")
if access.managers is not None:
for field in ("users", "groups"):
if any(not entry.strip() for entry in getattr(access.managers, field)):
raise HTTPException(422, f"access.managers.{field}: entries must be non-empty")
if any(not entry.strip() for entry in access.managers.users):
raise HTTPException(422, "access.managers.users: entries must be non-empty")
if any(not group_path(entry) for entry in access.managers.groups):
raise HTTPException(
422, "access.managers.groups: entries must name a group, such as finance or /parent/child"
)


def _check_source(source: Any) -> None:
Expand Down
10 changes: 6 additions & 4 deletions api/src/nebari_apps_api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ class AppRuntime(BaseModel):
# When set, the app runs as a Python/pixi service: the operator installs
# the pixi environment and executes `pixi run <pixiTask>`. The task must
# start a server on $PORT (8080, or 8081 when the sidecar fronts it:
# restricted apps and apps that can idle).
# apps that can idle).
pixiTask: str = Field(default="", max_length=64)


Expand All @@ -159,9 +159,11 @@ class AppAccess(BaseModel):
# Unknown keys are ignored on reads: an App stored before the users list was
# removed from access may still carry it (the apiserver prunes it on the next write).
public: bool = False
# A private app with groups is enforced in the app pod by the authz
# sidecar: a member of any group gets in, everyone else gets 403. Empty:
# any signed-in user.
# A private app with groups is enforced at the gateway (nebari-operator
# auth.groups): a member of any group gets in, every other signed-in user
# gets 403. Entries are full group paths ("finance" is "/finance", which
# does not admit members of "/finance/analysts" only). Empty: any
# signed-in user.
groups: list[str] = Field(default_factory=list)
# Users and groups who may manage the App like its owner; only the owner
# and admins may change this list.
Expand Down
4 changes: 2 additions & 2 deletions api/tests/test_apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -524,8 +524,8 @@ def test_access_users_is_refused(client, store):
assert "users" not in store.apps[("apps", "docs-site")]["spec"]["access"]


@pytest.mark.parametrize("entry", ["a,b", " ", ""])
def test_access_group_entries_must_be_single_names(client, store, entry):
@pytest.mark.parametrize("entry", ["/", "//", " ", ""])
def test_access_group_entries_must_name_a_group(client, store, entry):
field = "groups"
body = make_app_body()
body["access"][field] = [entry]
Expand Down
16 changes: 16 additions & 0 deletions api/tests/test_authz.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,19 @@ def test_group_membership_comes_from_the_token(as_user, alice_app):
assert as_user(User(username="x", groups=["Dave"])).get(f"{PREFIX}/apps/apps/alices").status_code == 403


def test_groups_match_as_full_paths(as_user, alice_app):
"""Groups match the way nebari-operator matches access.groups: "web-team"
and "/web-team" are the same group, a subgroup or a same-named group under
another parent is not, and neither is a parent of the listed group.
"""
get = lambda u: as_user(u).get(f"{PREFIX}/apps/apps/alices").status_code # noqa: E731
assert get(User(username="x", groups=["/web-team/"])) == 200
assert get(User(username="x", groups=["/web-team/frontend"])) == 403
assert get(User(username="x", groups=["/eng/web-team"])) == 403
assert get(User(username="x", groups=["/team/admin"])) == 403
assert get(User(username="x", groups=["/admin"])) == 200


NEW_MANAGERS = {"users": ["mallory"], "groups": []}


Expand Down Expand Up @@ -225,6 +238,9 @@ def test_owner_and_admin_can_change_managers(as_user, alice_app, store, user):
def test_blank_manager_entries_rejected(as_user, alice_app):
resp = as_user(ALICE).patch(f"{PREFIX}/apps/apps/alices", json=access_with({"users": [" "]}))
assert resp.status_code == 422
resp = as_user(ALICE).patch(f"{PREFIX}/apps/apps/alices", json=access_with({"groups": ["/"]}))
assert resp.status_code == 422
assert "access.managers.groups" in resp.json()["detail"]


def test_manager_of_unowned_app(as_user, store):
Expand Down
Loading
Loading