diff --git a/DOCS.md b/DOCS.md index 0f1aaa30d..808bd5b8c 100644 --- a/DOCS.md +++ b/DOCS.md @@ -895,6 +895,8 @@ This path requires exactly one enabled managed human admin and exactly one activ - Set `ENGRAM_CLOUD_TOKEN_PEPPER` to enable managed-token authentication. A token issued by `engram cloud bootstrap admin --issue-token` (or by the dashboard/`/admin/*` token-create routes) then authenticates directly against `/sync/*` and `/admin/*`, and can log into the dashboard as its resolved principal/role. - If `ENGRAM_CLOUD_TOKEN_PEPPER` is not set, managed-token authentication is simply disabled: the server still starts normally, and `ENGRAM_CLOUD_TOKEN` / `ENGRAM_CLOUD_ADMIN` continue to authenticate exactly as before (legacy-only mode). - Managed principals are deny-by-default for project sync: a managed token only reaches projects explicitly granted via `--grant-project` (or the dashboard/`/admin/*` grant routes). Legacy `ENGRAM_CLOUD_TOKEN` keeps its existing `ENGRAM_CLOUD_ALLOWED_PROJECTS` allowlist behavior, unaffected by managed grants. +- Managed dashboard inventory, details, statistics, browser views, and project sync controls use the principal's grants, not `ENGRAM_CLOUD_ALLOWED_PROJECTS`. Zero grants expose no projects; wildcard access must be explicitly granted. Legacy dashboard credentials remain env-allowlist restricted. +- Explicit `cloud_project_controls` rows register projects, including empty projects shown with zero counts. Existing projects with synced content remain visible for compatibility, subject to the same credential scope. Updating project sync controls refreshes dashboard inventory. - Disabled managed users, revoked managed tokens, and revoked project grants stop authenticating/authorizing on the very next request — no server restart required. - No rollback action is required to keep using legacy credentials: legacy `ENGRAM_CLOUD_TOKEN` continues to use its existing sync allowlist, and `ENGRAM_CLOUD_ADMIN` continues to provide dashboard read access, project sync controls, audit logs, and the first-admin dashboard bootstrap entry point whether or not `ENGRAM_CLOUD_TOKEN_PEPPER` is configured. The CLI recovery command remains limited to its documented stranded-admin state. Use a managed admin token for managed-user administration. diff --git a/internal/cloud/cloudstore/dashboard_principal_test.go b/internal/cloud/cloudstore/dashboard_principal_test.go index fec00e414..7c195a983 100644 --- a/internal/cloud/cloudstore/dashboard_principal_test.go +++ b/internal/cloud/cloudstore/dashboard_principal_test.go @@ -99,7 +99,7 @@ func TestDashboardPrincipalScopeHonorsDeploymentWildcardAndZeroGrants(t *testing } } -func TestDashboardPrincipalSyncControlsIntersectDeploymentAndGrants(t *testing.T) { +func TestDashboardPrincipalScopeSyncControlsUseGrantsOnly(t *testing.T) { store := &CloudStore{ dashboardAllowedScopes: map[string]struct{}{"project-a": {}}, dashboardReadModel: dashboardPrincipalReadModel(), @@ -110,20 +110,20 @@ func TestDashboardPrincipalSyncControlsIntersectDeploymentAndGrants(t *testing.T if err != nil { t.Fatalf("DashboardStoreForProjects wildcard: %v", err) } - assertDashboardPrincipalProjects(t, wildcard, "project-a") - if _, err := wildcard.scopedProject("project-b"); !errors.Is(err, ErrDashboardProjectForbidden) { - t.Fatalf("expected explicit deployment scope to forbid project-b, got %v", err) + if rows, err := wildcard.ListProjects(""); err != nil || len(rows) != 2 { + t.Fatalf("explicit managed wildcard must include both projects: %+v %v", rows, err) + } + if _, err := wildcard.scopedProject("project-b"); err != nil { + t.Fatalf("managed wildcard must allow project-b controls: %v", err) } stale, err := store.DashboardStoreForProjects([]string{"project-b"}) if err != nil { t.Fatalf("DashboardStoreForProjects stale grant: %v", err) } - if projects, err := stale.ListProjects(""); err != nil || len(projects) != 0 { - t.Fatalf("expected stale grant to expose no projects, projects=%+v err=%v", projects, err) - } - if _, err := stale.scopedProject("project-b"); !errors.Is(err, ErrDashboardProjectForbidden) { - t.Fatalf("expected stale grant to forbid project-b controls, got %v", err) + assertDashboardPrincipalProjects(t, stale, "project-b") + if _, err := stale.scopedProject("project-b"); err != nil { + t.Fatalf("grant outside legacy scope must allow controls: %v", err) } } @@ -133,7 +133,7 @@ func TestDashboardPrincipalSyncControlsRejectInvalidRecords(t *testing.T) { dashboardReadModel: dashboardPrincipalReadModel(), dashboardReadModelOK: true, } - view, err := store.DashboardStoreForProjects([]string{"*"}) + view, err := store.DashboardStoreForProjects([]string{"project-a"}) if err != nil { t.Fatalf("DashboardStoreForProjects: %v", err) } @@ -151,7 +151,7 @@ func TestDashboardPrincipalSyncControlsRejectInvalidRecords(t *testing.T) { } func TestDashboardPrincipalScopeKeepsSharedCacheIsolatedBetweenSequentialPrincipals(t *testing.T) { - store := &CloudStore{dashboardReadModel: dashboardPrincipalReadModel(), dashboardReadModelOK: true} + store := &CloudStore{dashboardReadModel: dashboardPrincipalReadModel(), dashboardReadModelOK: true, dashboardAllowedScopes: map[string]struct{}{"project-a": {}}} alice, err := store.DashboardStoreForProjects([]string{"project-a"}) if err != nil { @@ -166,6 +166,50 @@ func TestDashboardPrincipalScopeKeepsSharedCacheIsolatedBetweenSequentialPrincip assertDashboardPrincipalProjects(t, alice, "project-a") } +func TestDashboardPrincipalScopeOutsideLegacyAndEmptyRegistration(t *testing.T) { + model := dashboardPrincipalReadModel().withRegisteredProjects([]ProjectSyncControl{{Project: "empty"}}) + store := &CloudStore{dashboardReadModel: model, dashboardReadModelOK: true, dashboardAllowedScopes: map[string]struct{}{"project-a": {}}} + view, err := store.DashboardStoreForProjects([]string{"project-b", "empty"}) + if err != nil { + t.Fatal(err) + } + rows, err := view.ListProjects("") + if err != nil || len(rows) != 2 { + t.Fatalf("managed inventory: %+v %v", rows, err) + } + empty, err := view.ProjectDetail("empty") + if err != nil || empty.Stats != (DashboardProjectRow{Project: "empty"}) { + t.Fatalf("empty registration: %+v %v", empty, err) + } + if _, err := view.ProjectDetail("project-a"); !errors.Is(err, ErrDashboardProjectForbidden) { + t.Fatalf("ungranted detail: %v", err) + } + if obs := mustDashboardPrincipalObservations(t, view); len(obs) != 1 || obs[0].Project != "project-b" { + t.Fatalf("observations: %+v", obs) + } + if sessions := mustDashboardPrincipalSessions(t, view); len(sessions) != 1 || sessions[0].Project != "project-b" { + t.Fatalf("sessions: %+v", sessions) + } + if prompts := mustDashboardPrincipalPrompts(t, view); len(prompts) != 1 || prompts[0].Project != "project-b" { + t.Fatalf("prompts: %+v", prompts) + } + if overview, err := view.AdminOverview(); err != nil || overview != (DashboardAdminOverview{Projects: 2, Contributors: 1, Chunks: 3}) { + t.Fatalf("managed stats: %+v %v", overview, err) + } + legacy, err := store.ListProjects("") + if err != nil || len(legacy) != 1 || legacy[0].Project != "project-a" { + t.Fatalf("legacy inventory: %+v %v", legacy, err) + } + if _, err := store.ProjectDetail("project-b"); !errors.Is(err, ErrDashboardProjectForbidden) { + t.Fatalf("legacy exclusion: %v", err) + } + store.dashboardAllowedScopes = map[string]struct{}{"*": {}} + store.dashboardAllowedAll = true + if rows, err := store.ListProjects(""); err != nil || len(rows) != 3 { + t.Fatalf("legacy explicit wildcard: %+v %v", rows, err) + } +} + func mustDashboardPrincipalObservations(t *testing.T, view *DashboardScopedStore) []DashboardObservationRow { t.Helper() rows, _, err := view.ListRecentObservationsPaginated("", "", "", 10, 0) diff --git a/internal/cloud/cloudstore/dashboard_queries.go b/internal/cloud/cloudstore/dashboard_queries.go index 86ea5f076..90b95600e 100644 --- a/internal/cloud/cloudstore/dashboard_queries.go +++ b/internal/cloud/cloudstore/dashboard_queries.go @@ -888,7 +888,16 @@ func (m dashboardReadModel) filterPrompts(project, query string) []DashboardProm return rows } +// loadDashboardReadModel applies legacy deployment policy at every read boundary. func (cs *CloudStore) loadDashboardReadModel() (dashboardReadModel, error) { + model, err := cs.loadDashboardNeutralReadModel() + if err != nil { + return dashboardReadModel{}, err + } + return model.scoped(cs.dashboardAllowedScopes), nil +} + +func (cs *CloudStore) loadDashboardNeutralReadModel() (dashboardReadModel, error) { if cs == nil { return dashboardReadModel{}, fmt.Errorf("cloudstore: not initialized") } @@ -937,7 +946,30 @@ func (cs *CloudStore) buildDashboardReadModel() (dashboardReadModel, error) { if err != nil { return dashboardReadModel{}, err } - return model.scoped(cs.dashboardAllowedScopes), nil + controls, err := cs.ListProjectSyncControls() + if err != nil { + return dashboardReadModel{}, err + } + return model.withRegisteredProjects(controls), nil +} + +// withRegisteredProjects preserves content-derived inventory and adds empty registrations. +func (m dashboardReadModel) withRegisteredProjects(controls []ProjectSyncControl) dashboardReadModel { + for _, control := range controls { + project := strings.TrimSpace(control.Project) + if project == "" { + continue + } + if _, exists := m.projectDetails[project]; exists { + continue + } + row := DashboardProjectRow{Project: project} + m.projects = append(m.projects, row) + m.projectDetails[project] = DashboardProjectDetail{Project: project, Stats: row} + } + sort.Slice(m.projects, func(i, j int) bool { return m.projects[i].Project < m.projects[j].Project }) + m.admin.Projects = len(m.projects) + return m } func (cs *CloudStore) ListProjects(query string) ([]DashboardProjectRow, error) { @@ -1078,18 +1110,17 @@ func (cs *CloudStore) AdminOverview() (DashboardAdminOverview, error) { } // DashboardScopedStore is an immutable, request-owned view over CloudStore's -// deployment-scoped dashboard read model. It never updates the shared cache. +// permission-neutral dashboard read model. It never updates the shared cache. type DashboardScopedStore struct { base *CloudStore model dashboardReadModel allowed map[string]struct{} } -// DashboardStoreForProjects intersects the already deployment-scoped cached -// model with the supplied principal grants. An empty grant set intentionally -// produces an empty view rather than widening access. +// DashboardStoreForProjects scopes the neutral cache by managed grants only. +// An empty grant set intentionally produces an empty view, never wildcard access. func (cs *CloudStore) DashboardStoreForProjects(projects []string) (*DashboardScopedStore, error) { - model, err := cs.loadDashboardReadModel() + model, err := cs.loadDashboardNeutralReadModel() if err != nil { return nil, err } @@ -1107,20 +1138,7 @@ func (cs *CloudStore) DashboardStoreForProjects(projects []string) (*DashboardSc } allowed := principalAllowed - if !cs.dashboardAllowedAll && len(cs.dashboardAllowedScopes) > 0 { - allowed = make(map[string]struct{}) - if principalAll { - for project := range cs.dashboardAllowedScopes { - allowed[project] = struct{}{} - } - } else { - for project := range principalAllowed { - if _, ok := cs.dashboardAllowedScopes[project]; ok { - allowed[project] = struct{}{} - } - } - } - } else if principalAll { + if principalAll { allowed = map[string]struct{}{"*": {}} } @@ -1533,24 +1551,8 @@ func (cs *CloudStore) loadChunkRows(project string) ([]dashboardChunkRow, error) project = strings.TrimSpace(project) query := `SELECT chunk_id, project_name, created_by, created_at, payload FROM cloud_chunks` args := []any{} - if project == "" && !cs.dashboardAllowedAll && len(cs.dashboardAllowedScopes) > 0 { - allowed := make([]string, 0, len(cs.dashboardAllowedScopes)) - for name := range cs.dashboardAllowedScopes { - allowed = append(allowed, name) - } - sort.Strings(allowed) - query += ` WHERE project_name = ANY($1)` - args = append(args, allowed) - } + // Cache inputs are permission-neutral; public readers apply credential scope. if project != "" { - if !cs.dashboardAllowedAll && len(cs.dashboardAllowedScopes) > 0 { - if _, ok := cs.dashboardAllowedScopes[project]; !ok { - return []dashboardChunkRow{}, nil - } - } - if len(args) > 0 { - return nil, fmt.Errorf("cloudstore: internal dashboard query invariant violated") - } query += ` WHERE project_name = $1` args = append(args, project) } @@ -1590,24 +1592,8 @@ func (cs *CloudStore) loadMutationRows(project string) ([]dashboardMutationRow, project = strings.TrimSpace(project) query := `SELECT seq, project, entity, entity_key, op, payload::text, occurred_at FROM cloud_mutations` args := []any{} - if project == "" && !cs.dashboardAllowedAll && len(cs.dashboardAllowedScopes) > 0 { - allowed := make([]string, 0, len(cs.dashboardAllowedScopes)) - for name := range cs.dashboardAllowedScopes { - allowed = append(allowed, name) - } - sort.Strings(allowed) - query += ` WHERE project = ANY($1)` - args = append(args, allowed) - } + // Cache inputs are permission-neutral; public readers apply credential scope. if project != "" { - if !cs.dashboardAllowedAll && len(cs.dashboardAllowedScopes) > 0 { - if _, ok := cs.dashboardAllowedScopes[project]; !ok { - return []dashboardMutationRow{}, nil - } - } - if len(args) > 0 { - return nil, fmt.Errorf("cloudstore: internal dashboard mutation query invariant violated") - } query += ` WHERE project = $1` args = append(args, project) }