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
20 changes: 18 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,19 @@ store.Repository**.
the `@gen` alias). All three surfaces (web, CLI, MCP) speak the same RPCs.
- `backend/internal/server` wires the mux: unauthenticated `/healthz`, `/readyz`, `/auth/config`,
`/config.json` (runtime branding); the FrameService at its generated path behind
`auth.NewInterceptor`; optionally the MCP component; and the embedded SPA at `/`.
`auth.NewInterceptor`; optionally the MCP component; and the embedded SPA at `/`. The
FrameService is injected rather than constructed here, so the Connect and MCP endpoints share one
instance and cannot be configured differently. Request bodies are capped at
`server.MaxRequestBytes`, because a body is read in full before RBAC is consulted.
- `backend/internal/auth` resolves identity. `FRAMES_DEV_MODE=true` short-circuits everything with a
fixed `dev-user` identity and a permanently-ready `/readyz`. Otherwise a lazy OIDC validator
performs discovery from inside the pod, and `/readyz` stays 503 until it succeeds (fail closed).
- `backend/internal/orgs` turns claims into an `rbac.Caller`. Precedence is fixed: a stored
membership, then a pending invite matched by email, then the baseline role from
`FRAMES_DEFAULT_ROLE` (empty means deny, which is the fail-closed default). The baseline
membership is persisted, so those users appear in the members list and are promotable - and it is
written insert-only, because the "no membership" read that leads there is not atomic with the
write and an update would overwrite a role another request had just established.
- `backend/internal/rbac` is the single authorization decision point. `Can` evaluates in order:
cross-org deny, admin allow, then per-frame grants for the user or the org. Roles are
`viewer | publisher | admin`, permissions `read | edit | delete`. A missing read permission is
Expand All @@ -82,7 +91,10 @@ store.Repository**.
slots win last, cycles produce `CycleError`, and an unreadable ancestor propagates
`ErrParentUnreadable` rather than silently dropping content.
- `backend/internal/store` defines `Repository` plus an in-memory implementation used by tests;
`store/sqlite` is the real one, with goose migrations in `store/sqlite/migrations`. Publishes go
`store/sqlite` is the real one, with goose migrations in `store/sqlite/migrations`. Emails are
canonical at rest (`store.CanonicalEmail`) and unique per org case-insensitively, so an invite
cannot be shadowed by a case variant. The in-memory fake enforces the same unique constraints;
where it cannot, tests reach for real SQLite and say why. Publishes go
through `CreateFrameVersion`, which inserts the frame row, version, inheritance edges, and grants
atomically. **SQLite is single-writer, so the deployment is pinned to one replica.**
- `backend/internal/mcp` is a thin protocol adapter over `frames.Service`, exposing frames as MCP
Expand All @@ -103,6 +115,10 @@ store.Repository**.
## Conventions

- Table-driven Go tests. Tests sit beside the code they cover, including in `web/` (`*.test.tsx`).
- Migrations are only ever applied to a fresh database by the normal suite, which hides anything that
breaks on existing rows. `store/sqlite/migrations/migrate_legacy_test.go` builds an older schema
with the data a migration has to repair and migrates forward; extend it when a migration touches
existing rows.
- Never bump `chart/Chart.yaml`'s `version`/`appVersion` by hand: the release job stamps them from
the git tag (`version` = tag without `v`, `appVersion` = the literal tag).
- The SPA ships inside the image, not the chart. A frontend change reaches a cluster only through a
Expand Down
43 changes: 41 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Starts Keycloak in Docker (`:8081`) with an auto-imported realm and runs the bac

### Troubleshooting

**"No organization access" after login.** This is intentional fail-closed behavior: a signed-in user who is not a member of any org is denied. Locally, `make dev` seeds you (`dev-user`) as an admin, and `make dev-auth` seeds `dev@localhost` as a pending admin that activates on first login - so neither should show this page. If you see it against a real deployment, ask an org admin to add your email.
**"No organization access" after login.** This is intentional fail-closed behavior: a signed-in user who is not a member of any org is denied. Locally, `make dev` seeds you (`dev-user`) as an admin, and `make dev-auth` seeds `dev@localhost` as a pending admin that activates on first login - so neither should show this page. If you see it against a real deployment, ask an org admin to add your email, or set `auth.defaultRole` to admit every authenticated user at a baseline role (see [Default role for authenticated users](#default-role-for-authenticated-users)).

**`disk I/O error` / `database is locked` on startup.** A previous dev backend was left running (e.g. `make dev` was suspended with Ctrl-Z or killed with `kill -9` instead of stopped with a single Ctrl-C) and still holds the SQLite lock. Run `make dev-clean` to stop the orphan (it frees ports `:5173`/`:8080`), clear the dev DB and its `-wal`/`-shm` files, and reset Keycloak, then start again. Always stop a dev loop with a single **Ctrl-C** so both processes shut down cleanly.

Expand Down Expand Up @@ -81,6 +81,7 @@ For a standalone binary or a non-chart deployment, the equivalent environment va
| `OIDC_DEVICE_CLIENT_ID` | OIDC client id for the device-flow login used by `frames auth login`. |
| `OIDC_GROUPS_CLAIM` | Claim to read group membership from. Defaults to `groups`. |
| `FRAMES_DEV_MODE` | Set to exactly `true` to disable auth (dev only). |
| `FRAMES_DEFAULT_ROLE` | Baseline role for an authenticated user with no membership: unset (deny, the default), `viewer`, `publisher`, or `admin`. Requires `SEED_ORG_SLUG`. |

`FRAMES_DEV_MODE=true` short-circuits everything else. Otherwise both `OIDC_ISSUER_URL` and `OIDC_CLIENT_ID` are required, or the server fails fast on startup with a message naming the missing variable.

Expand All @@ -90,6 +91,7 @@ Beyond the auth block above, the values most people end up touching are:

- `nebariapp.hostname` - the hostname the operator routes to the app.
- `seed.orgSlug`, `seed.orgDisplayName`, `seed.adminEmail` - the organization created on first boot, and the email that is reconciled to the first real admin on their first login.
- `auth.defaultRole` - baseline role for any authenticated user who has no membership yet. Empty (the default) keeps today's behavior: a signed-in user with no invite is denied. See [Default role for authenticated users](#default-role-for-authenticated-users).
- `persistence.size`, `persistence.storageClass` - PVC size and storage class for the SQLite database.
- `mcp.enabled`, `mcp.publicUrl` - whether the `/mcp` endpoint is mounted, and an override for its public URL when it can't be derived from `nebariapp.hostname`.
- `branding.*` - white-label the app: title, logo (light and dark), favicon, and theme colors. Delivered at runtime, so no image rebuild is needed; leaving the block empty keeps the built-in Nebari branding. See [Branding](chart/README.md#branding).
Expand All @@ -111,12 +113,49 @@ helm install nebari-frames oci://quay.io/nebari/charts/nebari-frames --version 0

Installing from a git checkout also works, as shown in [`chart/README.md`](chart/README.md#install-on-nebari).

### Default role for authenticated users

By default, signing in is not enough: a user needs a membership, created by an admin invite or by
`seed.adminEmail`. A valid SSO user with no invite gets the "No organization access" screen.

Setting `auth.defaultRole` (env `FRAMES_DEFAULT_ROLE`) changes that. Any authenticated user with no
membership is admitted to `seed.orgSlug` at that role, and the membership is written on their first
request, so they appear in the admin members list and can be promoted from there.

```bash
helm upgrade --install nebari-frames ... \
--set auth.defaultRole=viewer \
--set seed.orgSlug=my-org
```

Consequences to weigh before enabling it:

- **The identity provider becomes the access boundary.** Anyone the realm admits can read every
Frame shared with the org. This is only safe if realm registration is closed or SSO-gated.
- **Removing a member stops being a revocation.** The removed user is re-provisioned at the default
role on their next request. To actually revoke access, disable the user in Keycloak, or unset
`auth.defaultRole` and manage membership explicitly.
- **"Add member" stops working for anyone who has already signed in.** They already hold a
membership, so adding them by email is rejected as already present. Change their role from the
members list instead. If their sign-in address differs from the one you invite (a different
address, not just different capitalization), the invite is accepted and then never applies, because
they already have a membership - see
[#66](https://github.com/nebari-dev/nebari-frames/issues/66).
- **Set `seed.adminSub` (or `seed.adminEmail`) as well.** A user who has signed in at the baseline
role already has a membership, which is why the server promotes the configured admin whenever the
organization has none. That recovery only works if an admin is configured, so configure one -
and prefer `seed.adminSub`, since it identifies the user by their stable subject rather than by an
address that may not match what their token carries.

An invalid role, or a role set without `seed.orgSlug`, fails at startup with a message naming the
variable rather than silently denying every request.

## Known Limitations

- **SQLite is single-writer.** `replicaCount` must stay `1`; the chart defaults to it and documents why in [`chart/README.md`](chart/README.md#values-reference). There is no highly-available mode yet.
- **OIDC discovery happens from inside the pod.** The backend resolves and validates the issuer URL itself at startup, so the pod must be able to resolve the issuer's hostname and trust its TLS certificate. This fails on clusters where the external Keycloak hostname isn't resolvable in-cluster, or where Keycloak serves a certificate the pod doesn't already trust.
- **One organization in the MVP.** `seed.orgSlug` seeds a single organization; there's no cross-org sharing or multi-org UI yet.
- **Role assignment is per-membership.** Each org membership carries its own role today. Keycloak group-to-role mapping is tracked in [#21](https://github.com/nebari-dev/nebari-frames/issues/21) and a default role for new members in [#22](https://github.com/nebari-dev/nebari-frames/issues/22).
- **Role assignment is per-membership.** Each org membership carries its own role today. `auth.defaultRole` sets the floor for users with no membership; Keycloak group-to-role mapping, which would raise it per group, is tracked in [#21](https://github.com/nebari-dev/nebari-frames/issues/21).

## Troubleshooting a Nebari Deployment

Expand Down
44 changes: 42 additions & 2 deletions backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import (
"github.com/nebari-dev/nebari-frames/backend/internal/devfixture"
"github.com/nebari-dev/nebari-frames/backend/internal/frames"
mcppkg "github.com/nebari-dev/nebari-frames/backend/internal/mcp"
"github.com/nebari-dev/nebari-frames/backend/internal/orgs"
"github.com/nebari-dev/nebari-frames/backend/internal/rbac"
"github.com/nebari-dev/nebari-frames/backend/internal/seed"
"github.com/nebari-dev/nebari-frames/backend/internal/server"
sqlitestore "github.com/nebari-dev/nebari-frames/backend/internal/store/sqlite"
Expand Down Expand Up @@ -71,6 +73,17 @@ func main() {
slog.Error("invalid auth configuration", "error", err)
os.Exit(1)
}
defaultMembership, err := selectDefaultMembership(os.Getenv("FRAMES_DEFAULT_ROLE"), os.Getenv("SEED_ORG_SLUG"))
if err != nil {
slog.Error("invalid default role configuration", "error", err)
os.Exit(1)
}
if defaultMembership.Role != "" {
slog.Warn("FRAMES_DEFAULT_ROLE is set - every authenticated user gets this role in the org, "+
"and removing a member only demotes them to it; revoke access in the identity provider instead",
"role", string(defaultMembership.Role), "org", defaultMembership.OrgSlug)
}

var validator auth.TokenValidator
if devMode {
slog.Warn("FRAMES_DEV_MODE=true - authentication DISABLED; injecting fixed dev-user identity")
Expand All @@ -94,9 +107,9 @@ func main() {
}
// Kept as a server.Mounter (interface) so a disabled endpoint is a nil
// interface, not a typed-nil *Component that would satisfy a != nil check.
framesService := frames.NewService(repo, frames.WithDefaultMembership(defaultMembership))
var mcpMounter server.Mounter
if mcpCfg.PublicURL != "" || devMode {
framesService := frames.NewService(repo)
mcpMounter = mcppkg.NewComponent(mcpCfg, framesService, mcpValidator)
}

Expand All @@ -109,7 +122,7 @@ func main() {

srv := &http.Server{
Addr: ":" + port,
Handler: server.New(repo, validator, authCfg, brandingCfg, devMode, mcpMounter).Handler(),
Handler: server.New(framesService, validator, authCfg, brandingCfg, devMode, mcpMounter).Handler(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
Expand All @@ -129,6 +142,33 @@ func envOr(key, fallback string) string {
return fallback
}

// selectDefaultMembership resolves FRAMES_DEFAULT_ROLE into the baseline
// membership granted to authenticated callers with no stored membership. An
// empty role means deny, preserving the fail-closed default. Anything set but
// unusable is a startup error rather than a silent denial, because an operator
// who configured a default role and got none would have no way to tell.
func selectDefaultMembership(roleEnv, orgSlug string) (orgs.DefaultMembership, error) {
roleEnv = strings.TrimSpace(roleEnv)
orgSlug = strings.TrimSpace(orgSlug)
if roleEnv == "" {
return orgs.DefaultMembership{}, nil
}
role, ok := rbac.ParseRole(roleEnv)
if !ok {
return orgs.DefaultMembership{}, fmt.Errorf(
"FRAMES_DEFAULT_ROLE=%q is not a valid role; use one of viewer, publisher, admin, or leave it unset to require explicit membership",
roleEnv,
)
}
if orgSlug == "" {
return orgs.DefaultMembership{}, fmt.Errorf(
"FRAMES_DEFAULT_ROLE=%q needs an organization to grant access to, but SEED_ORG_SLUG is not set",
roleEnv,
)
}
return orgs.DefaultMembership{Role: role, OrgSlug: orgSlug}, nil
}

// selectAuthMode resolves the auth bootstrap decision from environment values.
// FRAMES_DEV_MODE=true (exactly) disables auth. Otherwise OIDC_ISSUER_URL and
// OIDC_CLIENT_ID are both required; a missing one is a fatal misconfiguration.
Expand Down
80 changes: 80 additions & 0 deletions backend/cmd/server/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ package main
import (
"strings"
"testing"

"github.com/nebari-dev/nebari-frames/backend/internal/orgs"
"github.com/nebari-dev/nebari-frames/backend/internal/rbac"
)

func TestSelectAuthMode(t *testing.T) {
Expand Down Expand Up @@ -46,3 +49,80 @@ func TestSelectAuthMode(t *testing.T) {
})
}
}

func TestSelectDefaultMembership(t *testing.T) {
tests := []struct {
name string
roleEnv string
orgSlug string
want orgs.DefaultMembership
wantErr bool
errNames string // substring the error must mention
}{
{
name: "unset denies, which is the fail-closed default",
roleEnv: "",
orgSlug: "acme",
want: orgs.DefaultMembership{},
},
{
name: "unset with no org is also fine",
roleEnv: "",
orgSlug: "",
want: orgs.DefaultMembership{},
},
{
name: "viewer resolves against the seeded org",
roleEnv: "viewer",
orgSlug: "acme",
want: orgs.DefaultMembership{Role: rbac.RoleViewer, OrgSlug: "acme"},
},
{
name: "surrounding whitespace is tolerated",
roleEnv: " viewer\n",
orgSlug: "acme",
want: orgs.DefaultMembership{Role: rbac.RoleViewer, OrgSlug: "acme"},
},
{
name: "admin is accepted, however unwise",
roleEnv: "admin",
orgSlug: "acme",
want: orgs.DefaultMembership{Role: rbac.RoleAdmin, OrgSlug: "acme"},
},
{
name: "an unknown role fails fast",
roleEnv: "superuser",
orgSlug: "acme",
wantErr: true,
errNames: "FRAMES_DEFAULT_ROLE",
},
{
name: "a role with no seeded org fails fast rather than silently denying",
roleEnv: "viewer",
orgSlug: "",
wantErr: true,
errNames: "SEED_ORG_SLUG",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := selectDefaultMembership(tt.roleEnv, tt.orgSlug)
if tt.wantErr {
if err == nil {
t.Fatalf("want error, got config %+v", got)
}
if !strings.Contains(err.Error(), tt.errNames) {
t.Errorf("error %q should name %q", err, tt.errNames)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("got %+v, want %+v", got, tt.want)
}
})
}
}
25 changes: 20 additions & 5 deletions backend/internal/frames/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,29 @@ import (
)

type Service struct {
repo store.Repository
lookup rbac.GrantLookup
repo store.Repository
lookup rbac.GrantLookup
defaultMembership orgs.DefaultMembership
}

// Option configures a Service. Options exist so that adding configuration does
// not churn every NewService call site; the zero configuration is fail-closed.
type Option func(*Service)

// WithDefaultMembership grants authenticated callers with no stored membership
// a baseline role in the named org. Omit it to deny such callers (the default).
func WithDefaultMembership(def orgs.DefaultMembership) Option {
return func(s *Service) { s.defaultMembership = def }
}

var _ framesv1connect.FrameServiceHandler = (*Service)(nil)

func NewService(repo store.Repository) *Service {
return &Service{repo: repo, lookup: grantLookup{repo}}
func NewService(repo store.Repository, opts ...Option) *Service {
s := &Service{repo: repo, lookup: grantLookup{repo}}
for _, opt := range opts {
opt(s)
}
return s
}

// grantLookup adapts store grants to rbac.Grant.
Expand Down Expand Up @@ -351,7 +366,7 @@ func (s *Service) ResolveFrame(ctx context.Context, req *connect.Request[framesv
// --- helpers ---

func (s *Service) resolveCaller(ctx context.Context) (rbac.Caller, error) {
caller, err := orgs.ResolveCaller(ctx, s.repo)
caller, err := orgs.ResolveCaller(ctx, s.repo, s.defaultMembership)
if err != nil {
switch {
case errors.Is(err, orgs.ErrNoClaims):
Expand Down
Loading
Loading