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
14 changes: 13 additions & 1 deletion .github/scripts/check_crd_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
Tightened validation (bounds, patterns, new CEL rules) is reported as a
warning: Kubernetes validation ratcheting lets existing objects keep updating,
but the change still belongs in the upgrade notes.

APPROVED_REMOVALS lists one-off exceptions to the policy, each recorded in the
App CRD reference and the upgrade notes. They are reported as warnings.
"""

from __future__ import annotations
Expand All @@ -27,6 +30,12 @@
errors: list[str] = []
warnings: list[str] = []

# Fields removed from v1alpha1 by an explicit, documented exception (see
# "Versioning and deprecation"). Entries can go once no base branch has the field.
APPROVED_REMOVALS = {
"v1alpha1.spec.access.users", # group-only access; see Upgrading
}


def compare(path: str, old: dict[str, Any], new: dict[str, Any]) -> None:
if old.get("type") != new.get("type"):
Expand Down Expand Up @@ -56,7 +65,10 @@ def compare(path: str, old: dict[str, Any], new: dict[str, Any]) -> None:
old_props, new_props = old.get("properties", {}), new.get("properties", {})
for name, schema in old_props.items():
if name not in new_props:
errors.append(f"{path}.{name}: removed")
if f"{path}.{name}" in APPROVED_REMOVALS:
warnings.append(f"{path}.{name}: removed (approved exception)")
else:
errors.append(f"{path}.{name}: removed")
else:
compare(f"{path}.{name}", schema, new_props[name])
newly_required = set(new.get("required", [])) - set(old.get("required", []))
Expand Down
4 changes: 2 additions & 2 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/users, 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`/`access.users` (403 otherwise); 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 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`) |
| `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` (device flow, sessions), `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
9 changes: 4 additions & 5 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` or `access.users` (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 list `access.groups` (returns `403` to everyone else) or that can idle (records when the app last served a request). | `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 Down Expand Up @@ -104,10 +104,9 @@ The user guide is at [packs.nebari.dev/apps-pack](https://packs.nebari.dev/apps-

## Known limitations

- `access.groups` and `access.users` are enforced in the app pod by the apps-authz sidecar, not
at the gateway; group changes apply when the user's token refreshes (5 minutes by default).
- Landing-page tile visibility is group-based only, so the launch form's landing-page switch is
locked for now; register a tile through the API, the MCP server or `kubectl`.
- `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).
- The launch form's landing-page switch is locked for now; register a tile through the API, the MCP server or `kubectl`.
- 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
3 changes: 1 addition & 2 deletions api/src/nebari_apps_api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,7 @@ 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, like
access.users) or through a group in the caller's verified token (a leading
"""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).
"""
managers = app_managers(cr)
Expand Down
1 change: 0 additions & 1 deletion api/src/nebari_apps_api/cr.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,6 @@ def to_cr(req: AppCreate, owner: str) -> dict[str, Any]:
"access": {
"public": req.access.public,
"groups": req.access.groups,
"users": req.access.users,
"managers": req.access.managers.model_dump() if req.access.managers else None,
"subdomain": req.access.subdomain,
},
Expand Down
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 @@ -13,7 +13,9 @@
import httpx
import structlog
from fastapi import Body, Depends, FastAPI, File, Form, HTTPException, Query, UploadFile
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, Response
from pydantic import ValidationError

from . import __version__
from . import cr as crmod
Expand Down Expand Up @@ -309,7 +311,11 @@ def create_from_upload(
data_bytes = read_capped(file.file, settings.max_upload_bytes)
files = files_from_upload(file.filename or "upload", data_bytes, pixi=pixi)
data["source"] = {"type": "inline", "inline": {"files": files}}
req = AppCreate.model_validate(data)
try:
req = AppCreate.model_validate(data)
except ValidationError as exc:
# A 422 like a JSON body gets, not a 500.
raise RequestValidationError(exc.errors()) from exc

return create_checked(store, user, req)

Expand Down Expand Up @@ -539,16 +545,15 @@ def entry(ns: str, name: str) -> dict[str, int]:


def _check_access(access: Any) -> None:
"""Groups and users reach the authz sidecar as comma-separated lists, so
an entry may not be blank or contain a comma (the operator refuses such an
App; this says so up front).
"""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).
"""
if access is None:
return
for field in ("groups", "users"):
for entry in getattr(access, field):
if not entry.strip() or "," in entry:
raise HTTPException(422, f"access.{field}: entries must be non-empty and must not contain ','")
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 access.managers is not None:
for field in ("users", "groups"):
if any(not entry.strip() for entry in getattr(access.managers, field)):
Expand Down
22 changes: 16 additions & 6 deletions api/src/nebari_apps_api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,18 +156,28 @@ class AppManagers(BaseModel):


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 or users is enforced in the app pod by the
# authz sidecar: a member of any group OR a listed username gets in,
# everyone else gets 403. Both empty: any signed-in user.
# 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.
groups: list[str] = Field(default_factory=list)
users: 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.
managers: AppManagers | None = None
subdomain: str = Field(max_length=63, pattern=r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")


class AppAccessRequest(AppAccess):
"""access in a create or patch body. Unknown keys are refused, so a client
still sending the removed users list gets a 422 instead of an App that is
silently open to more people than it asked for.
"""

model_config = ConfigDict(extra="forbid")


class AppLandingPage(BaseModel):
"""Whether the app is registered as a tile on the Nebari landing page.

Expand All @@ -190,7 +200,7 @@ class AppCreate(BaseModel):
thumbnail: str = ""
source: AppSource
runtime: AppRuntime = Field(default_factory=AppRuntime)
access: AppAccess
access: AppAccessRequest
landingPage: AppLandingPage | None = None
# Write-only secret values: stored in the App's Secret, never in the App
# and never returned; runtime.secretKeys lists their names.
Expand All @@ -207,7 +217,7 @@ class AppPatch(BaseModel):
thumbnail: str | None = None
source: AppSource | None = None
runtime: AppRuntime | None = None
access: AppAccess | None = None
access: AppAccessRequest | None = None
landingPage: AppLandingPage | None = None
# Set or rotate (a value) or remove (null) secret keys; keys not sent are
# kept. Write-only, like AppCreate.secrets.
Expand Down
56 changes: 21 additions & 35 deletions api/tests/test_apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -436,55 +436,37 @@ def test_landing_page_opt_in_and_out(client, store):
assert "landingPage" not in store.apps[("apps", "docs-site")]["spec"]


def test_access_users_round_trip_on_create(client, store):
body = make_app_body()
body["access"]["users"] = ["alice", "bob"]
body["access"]["groups"] = ["analysts"]
resp = client.post("/api/v1/apps", json=body)
assert resp.status_code == 201
stored = store.apps[("apps", "docs-site")]["spec"]["access"]
assert stored["users"] == ["alice", "bob"]
assert stored["groups"] == ["analysts"]
assert client.get("/api/v1/apps/apps/docs-site").json()["access"]["users"] == ["alice", "bob"]


def test_access_users_round_trip_on_upload(client, store):
def test_access_users_is_refused(client, store):
"""The users list was removed from access: a client still sending it gets a 422, not an
App open to more people than it asked for.
"""
import json

manifest = {k: v for k, v in make_app_body().items() if k != "source"}
manifest["access"]["users"] = ["alice"]
body = make_app_body()
body["access"]["users"] = ["alice"]
assert client.post("/api/v1/apps", json=body).status_code == 422

manifest = {k: v for k, v in body.items() if k != "source"}
resp = client.post(
"/api/v1/apps/upload",
data={"manifest": json.dumps(manifest)},
files={"file": ("index.html", b"<h1>hi</h1>", "text/html")},
)
assert resp.status_code == 201
assert store.apps[("apps", "docs-site")]["spec"]["access"]["users"] == ["alice"]

assert resp.status_code == 422
assert store.apps == {}

def test_access_users_round_trip_on_patch(client, store):
assert client.post("/api/v1/apps", json=make_app_body()).status_code == 201
resp = client.patch(
"/api/v1/apps/apps/docs-site",
json={"access": {"public": False, "users": ["alice"], "subdomain": "docs-site"}},
)
assert resp.status_code == 200
assert store.apps[("apps", "docs-site")]["spec"]["access"]["users"] == ["alice"]
assert resp.json()["access"]["users"] == ["alice"]
# An empty list clears it.
resp = client.patch(
"/api/v1/apps/apps/docs-site",
json={"access": {"public": False, "users": [], "groups": ["team"], "subdomain": "docs-site"}},
)
assert resp.status_code == 200
access = store.apps[("apps", "docs-site")]["spec"]["access"]
assert "users" not in access
assert access["groups"] == ["team"]
assert resp.status_code == 422
assert "users" not in store.apps[("apps", "docs-site")]["spec"]["access"]


@pytest.mark.parametrize("field", ["groups", "users"])
@pytest.mark.parametrize("entry", ["a,b", " ", ""])
def test_access_list_entries_must_be_single_names(client, store, field, entry):
def test_access_group_entries_must_be_single_names(client, store, entry):
field = "groups"
body = make_app_body()
body["access"][field] = [entry]
resp = client.post("/api/v1/apps", json=body)
Expand All @@ -500,10 +482,14 @@ def test_access_list_entries_must_be_single_names(client, store, field, entry):


def test_cr_with_users_still_readable(client, store):
"""A kubectl-authored CR that sets users must not break reads."""
"""A CR stored before the users list was removed from access must not break reads; the
field is not returned.
"""
assert client.post("/api/v1/apps", json=make_app_body()).status_code == 201
store.apps[("apps", "docs-site")]["spec"]["access"]["users"] = ["alice"]
assert client.get("/api/v1/apps/apps/docs-site").status_code == 200
resp = client.get("/api/v1/apps/apps/docs-site")
assert resp.status_code == 200
assert "users" not in resp.json()["access"]
assert client.get("/api/v1/apps").status_code == 200


Expand Down
21 changes: 6 additions & 15 deletions authz/internal/gate/config.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// Package gate is the apps-authz sidecar: a reverse proxy in front of an app
// container that lets a request through only when it carries a valid
// Keycloak access token whose groups claim names one of the allowed groups
// or whose username is one of the allowed users (either is enough).
// Keycloak access token whose groups claim names one of the allowed groups.
package gate

import (
Expand Down Expand Up @@ -41,16 +40,10 @@ type Config struct {
// GroupsClaim is the top-level claim holding the user's groups
// (AUTHZ_GROUPS_CLAIM, default "groups").
GroupsClaim string
// Users lets in these usernames, compared case-insensitively
// (AUTHZ_USERS, comma-separated).
Users []string
// UsersClaim is the top-level claim holding the username
// (AUTHZ_USERS_CLAIM, default "preferred_username").
UsersClaim string
// Passthrough proxies every request without checking a token
// (AUTHZ_PASSTHROUGH=true). The operator sets it on Apps that restrict
// nobody but may be scaled to zero when idle, where the sidecar only
// records activity. It cannot be combined with groups or users.
// records activity. It cannot be combined with groups.
Passthrough bool
}

Expand All @@ -65,8 +58,6 @@ func FromEnv(getenv func(string) string) (Config, error) {
ClientID: strings.TrimSpace(getenv("AUTHZ_CLIENT_ID")),
Groups: splitList(getenv("AUTHZ_GROUPS")),
GroupsClaim: orDefault(getenv("AUTHZ_GROUPS_CLAIM"), "groups"),
Users: splitList(getenv("AUTHZ_USERS")),
UsersClaim: orDefault(getenv("AUTHZ_USERS_CLAIM"), "preferred_username"),
Passthrough: strings.TrimSpace(getenv("AUTHZ_PASSTHROUGH")) == "true",
}

Expand All @@ -83,8 +74,8 @@ func FromEnv(getenv func(string) string) (Config, error) {
}
if cfg.Passthrough {
// Refuse rather than silently open an App that meant to restrict.
if len(cfg.Groups) > 0 || len(cfg.Users) > 0 {
errs = append(errs, errors.New("AUTHZ_PASSTHROUGH cannot be combined with AUTHZ_GROUPS or AUTHZ_USERS"))
if len(cfg.Groups) > 0 {
errs = append(errs, errors.New("AUTHZ_PASSTHROUGH cannot be combined with AUTHZ_GROUPS"))
}
return cfg, errors.Join(errs...)
}
Expand All @@ -99,10 +90,10 @@ func FromEnv(getenv func(string) string) (Config, error) {
if cfg.ClientID == "" {
errs = append(errs, errors.New("AUTHZ_CLIENT_ID is required"))
}
if len(cfg.Groups) == 0 && len(cfg.Users) == 0 {
if len(cfg.Groups) == 0 {
// The sidecar exists only to restrict; with nothing to allow it
// would deny everyone, which is a misconfiguration, not a policy.
errs = append(errs, errors.New("AUTHZ_GROUPS or AUTHZ_USERS is required (comma-separated)"))
errs = append(errs, errors.New("AUTHZ_GROUPS is required (comma-separated)"))
}
return cfg, errors.Join(errs...)
}
Expand Down
Loading
Loading