Skip to content

Commit 81e8728

Browse files
feat(cloud): add wildcard support to ENGRAM_CLOUD_ALLOWED_PROJECTS (#431)
Setting ENGRAM_CLOUD_ALLOWED_PROJECTS="*" now permits any project for both sync authorization and dashboard display, eliminating the need to maintain exhaustive project lists in local dev / CI environments. Closes #260
1 parent e09ebd5 commit 81e8728

5 files changed

Lines changed: 199 additions & 11 deletions

File tree

internal/cloud/auth/auth.go

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,18 @@ var ErrInvalidDashboardSessionToken = errors.New("invalid dashboard session toke
2222
var ErrProjectNotAllowed = errors.New("project is not allowed for this token")
2323

2424
type Service struct {
25-
store *cloudstore.CloudStore
26-
expectedToken string
27-
dashboardAuth map[string]struct{}
28-
allowed map[string]struct{}
29-
jwtSecret []byte
30-
now func() time.Time
25+
store *cloudstore.CloudStore
26+
expectedToken string
27+
dashboardAuth map[string]struct{}
28+
allowed map[string]struct{}
29+
allowedAll bool
30+
jwtSecret []byte
31+
now func() time.Time
3132
}
3233

3334
type ProjectScopeAuthorizer struct {
34-
allowed map[string]struct{}
35+
allowed map[string]struct{}
36+
allowedAll bool
3537
}
3638

3739
func NewService(store *cloudstore.CloudStore, jwtSecret string) (*Service, error) {
@@ -153,7 +155,12 @@ func (s *Service) SetDashboardSessionTokens(tokens []string) {
153155

154156
func (s *Service) SetAllowedProjects(projects []string) {
155157
s.allowed = make(map[string]struct{})
158+
s.allowedAll = false
156159
for _, project := range projects {
160+
if strings.TrimSpace(project) == "*" {
161+
s.allowedAll = true
162+
return
163+
}
157164
normalized, _ := store.NormalizeProject(project)
158165
normalized = strings.TrimSpace(normalized)
159166
if normalized == "" {
@@ -164,22 +171,42 @@ func (s *Service) SetAllowedProjects(projects []string) {
164171
}
165172

166173
func (s *Service) AuthorizeProject(project string) error {
174+
if s.allowedAll {
175+
normalized, _ := store.NormalizeProject(project)
176+
normalized = strings.TrimSpace(normalized)
177+
if normalized == "" {
178+
return fmt.Errorf("project is required")
179+
}
180+
return nil
181+
}
167182
return authorizeProjectAgainstAllowlist(project, s.allowed)
168183
}
169184

170185
// EnrolledProjects returns the sorted list of projects that this Service is
171186
// authorized to serve. Used by cloudserver's mutation pull to filter mutations
172187
// to the caller's enrolled projects (REQ-202).
173188
//
189+
// When the wildcard "*" is configured, nil is returned to signal "no project
190+
// filter" — callers must treat nil as "allow all" (matching the ListMutationsSince
191+
// nil-means-all contract).
192+
//
174193
// The interface is cloudserver.EnrolledProjectsProvider; this method makes
175194
// *Service satisfy it without importing cloudserver (structural assertion).
176195
func (s *Service) EnrolledProjects() []string {
196+
if s.allowedAll {
197+
return nil
198+
}
177199
return sortedAllowlist(s.allowed)
178200
}
179201

180202
func (a *ProjectScopeAuthorizer) SetAllowedProjects(projects []string) {
181203
a.allowed = make(map[string]struct{})
204+
a.allowedAll = false
182205
for _, project := range projects {
206+
if strings.TrimSpace(project) == "*" {
207+
a.allowedAll = true
208+
return
209+
}
183210
normalized, _ := store.NormalizeProject(project)
184211
normalized = strings.TrimSpace(normalized)
185212
if normalized == "" {
@@ -190,14 +217,28 @@ func (a *ProjectScopeAuthorizer) SetAllowedProjects(projects []string) {
190217
}
191218

192219
func (a *ProjectScopeAuthorizer) AuthorizeProject(project string) error {
220+
if a.allowedAll {
221+
normalized, _ := store.NormalizeProject(project)
222+
normalized = strings.TrimSpace(normalized)
223+
if normalized == "" {
224+
return fmt.Errorf("project is required")
225+
}
226+
return nil
227+
}
193228
return authorizeProjectAgainstAllowlist(project, a.allowed)
194229
}
195230

196231
// EnrolledProjects returns the sorted list of projects this authorizer allows.
197232
// Matches the cloudserver.EnrolledProjectsProvider contract so mutation pull
198233
// can filter server-side by the caller's enrolled projects (REQ-202) rather
199234
// than fail-closing to an empty result set.
235+
//
236+
// When the wildcard "*" is configured, nil is returned to signal "no project
237+
// filter" (matching the ListMutationsSince nil-means-all contract).
200238
func (a *ProjectScopeAuthorizer) EnrolledProjects() []string {
239+
if a.allowedAll {
240+
return nil
241+
}
201242
return sortedAllowlist(a.allowed)
202243
}
203244

internal/cloud/auth/auth_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,67 @@ func TestAuthorizeBearerTokenConstantTimeComparison(t *testing.T) {
290290
}
291291
}
292292

293+
// TestAuthorizeProjectWildcard tests that a single "*" in the allowlist permits any project.
294+
func TestAuthorizeProjectWildcard(t *testing.T) {
295+
svc, err := NewService(&cloudstore.CloudStore{}, strings.Repeat("x", 32))
296+
if err != nil {
297+
t.Fatalf("new service: %v", err)
298+
}
299+
300+
// "*" alone must allow any project.
301+
svc.SetAllowedProjects([]string{"*"})
302+
if err := svc.AuthorizeProject("any-project"); err != nil {
303+
t.Fatalf("wildcard allowlist must permit any project, got %v", err)
304+
}
305+
if err := svc.AuthorizeProject("ANOTHER-ONE"); err != nil {
306+
t.Fatalf("wildcard allowlist must permit uppercased project, got %v", err)
307+
}
308+
if err := svc.AuthorizeProject("team-foo"); err != nil {
309+
t.Fatalf("wildcard allowlist must permit prefixed project, got %v", err)
310+
}
311+
}
312+
313+
// TestAuthorizeProjectWildcardMixedWithExact tests that "*" in a mixed list still allows all.
314+
func TestAuthorizeProjectWildcardMixedWithExact(t *testing.T) {
315+
svc, err := NewService(&cloudstore.CloudStore{}, strings.Repeat("x", 32))
316+
if err != nil {
317+
t.Fatalf("new service: %v", err)
318+
}
319+
320+
svc.SetAllowedProjects([]string{"proj-a", "*"})
321+
if err := svc.AuthorizeProject("anything-at-all"); err != nil {
322+
t.Fatalf("wildcard in mixed list must still permit any project, got %v", err)
323+
}
324+
}
325+
326+
// TestProjectScopeAuthorizerWildcard tests that NewProjectScopeAuthorizer also respects "*".
327+
func TestProjectScopeAuthorizerWildcard(t *testing.T) {
328+
authorizer := NewProjectScopeAuthorizer([]string{"*"})
329+
if err := authorizer.AuthorizeProject("any-project"); err != nil {
330+
t.Fatalf("wildcard authorizer must permit any project, got %v", err)
331+
}
332+
if err := authorizer.AuthorizeProject("team-foo"); err != nil {
333+
t.Fatalf("wildcard authorizer must permit team-prefixed project, got %v", err)
334+
}
335+
}
336+
337+
// TestAuthorizeProjectExactMatchStillWorksAfterWildcardChange verifies backward compatibility.
338+
func TestAuthorizeProjectExactMatchStillWorksAfterWildcardChange(t *testing.T) {
339+
svc, err := NewService(&cloudstore.CloudStore{}, strings.Repeat("x", 32))
340+
if err != nil {
341+
t.Fatalf("new service: %v", err)
342+
}
343+
344+
// Exact allowlist: only listed projects pass.
345+
svc.SetAllowedProjects([]string{"proj-a", "proj-b"})
346+
if err := svc.AuthorizeProject("proj-a"); err != nil {
347+
t.Fatalf("exact match must still be allowed, got %v", err)
348+
}
349+
if err := svc.AuthorizeProject("proj-c"); !errors.Is(err, ErrProjectNotAllowed) {
350+
t.Fatalf("unlisted project must be rejected, got %v", err)
351+
}
352+
}
353+
293354
func TestDashboardSessionTokenSupportsAdditionalDashboardCredential(t *testing.T) {
294355
svc, err := NewService(&cloudstore.CloudStore{}, strings.Repeat("x", 32))
295356
if err != nil {

internal/cloud/cloudstore/cloudstore.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
type CloudStore struct {
2323
db *sql.DB
2424
dashboardAllowedScopes map[string]struct{}
25+
dashboardAllowedAll bool
2526
dashboardReadModelMu sync.RWMutex
2627
dashboardReadModel dashboardReadModel
2728
dashboardReadModelOK bool
@@ -65,9 +66,16 @@ func (cs *CloudStore) SetDashboardAllowedProjects(projects []string) {
6566
if cs == nil {
6667
return
6768
}
69+
cs.dashboardAllowedAll = false
6870
cs.dashboardAllowedScopes = make(map[string]struct{})
6971
for _, project := range projects {
7072
project = strings.TrimSpace(project)
73+
if project == "*" {
74+
cs.dashboardAllowedAll = true
75+
cs.dashboardAllowedScopes = nil
76+
cs.invalidateDashboardReadModel()
77+
return
78+
}
7179
if project == "" {
7280
continue
7381
}

internal/cloud/cloudstore/dashboard_queries.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -734,9 +734,13 @@ func applyDashboardMutation(
734734
}
735735

736736
func (m dashboardReadModel) scoped(allowed map[string]struct{}) dashboardReadModel {
737+
// Empty map or wildcard sentinel "*" means no filtering.
737738
if len(allowed) == 0 {
738739
return m
739740
}
741+
if _, ok := allowed["*"]; ok {
742+
return m
743+
}
740744
projects := make([]DashboardProjectRow, 0, len(m.projects))
741745
projectDetails := make(map[string]DashboardProjectDetail)
742746
totalChunks := 0
@@ -1047,6 +1051,9 @@ func (cs *CloudStore) normalizeDashboardProject(project string) (string, error)
10471051
if project == "" {
10481052
return "", fmt.Errorf("%w", ErrDashboardProjectInvalid)
10491053
}
1054+
if cs.dashboardAllowedAll {
1055+
return project, nil
1056+
}
10501057
if len(cs.dashboardAllowedScopes) > 0 {
10511058
if _, ok := cs.dashboardAllowedScopes[project]; !ok {
10521059
return "", fmt.Errorf("%w", ErrDashboardProjectForbidden)
@@ -1095,7 +1102,7 @@ func (cs *CloudStore) loadChunkRows(project string) ([]dashboardChunkRow, error)
10951102
project = strings.TrimSpace(project)
10961103
query := `SELECT chunk_id, project_name, created_by, created_at, payload FROM cloud_chunks`
10971104
args := []any{}
1098-
if project == "" && len(cs.dashboardAllowedScopes) > 0 {
1105+
if project == "" && !cs.dashboardAllowedAll && len(cs.dashboardAllowedScopes) > 0 {
10991106
allowed := make([]string, 0, len(cs.dashboardAllowedScopes))
11001107
for name := range cs.dashboardAllowedScopes {
11011108
allowed = append(allowed, name)
@@ -1105,7 +1112,7 @@ func (cs *CloudStore) loadChunkRows(project string) ([]dashboardChunkRow, error)
11051112
args = append(args, allowed)
11061113
}
11071114
if project != "" {
1108-
if len(cs.dashboardAllowedScopes) > 0 {
1115+
if !cs.dashboardAllowedAll && len(cs.dashboardAllowedScopes) > 0 {
11091116
if _, ok := cs.dashboardAllowedScopes[project]; !ok {
11101117
return []dashboardChunkRow{}, nil
11111118
}
@@ -1152,7 +1159,7 @@ func (cs *CloudStore) loadMutationRows(project string) ([]dashboardMutationRow,
11521159
project = strings.TrimSpace(project)
11531160
query := `SELECT seq, project, entity, entity_key, op, payload::text, occurred_at FROM cloud_mutations`
11541161
args := []any{}
1155-
if project == "" && len(cs.dashboardAllowedScopes) > 0 {
1162+
if project == "" && !cs.dashboardAllowedAll && len(cs.dashboardAllowedScopes) > 0 {
11561163
allowed := make([]string, 0, len(cs.dashboardAllowedScopes))
11571164
for name := range cs.dashboardAllowedScopes {
11581165
allowed = append(allowed, name)
@@ -1162,7 +1169,7 @@ func (cs *CloudStore) loadMutationRows(project string) ([]dashboardMutationRow,
11621169
args = append(args, allowed)
11631170
}
11641171
if project != "" {
1165-
if len(cs.dashboardAllowedScopes) > 0 {
1172+
if !cs.dashboardAllowedAll && len(cs.dashboardAllowedScopes) > 0 {
11661173
if _, ok := cs.dashboardAllowedScopes[project]; !ok {
11671174
return []dashboardMutationRow{}, nil
11681175
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package cloudstore
2+
3+
import (
4+
"errors"
5+
"testing"
6+
"time"
7+
)
8+
9+
// TestScopedWildcardPassesAllProjects verifies that a wildcard allowlist does not
10+
// filter the dashboard read model — all projects must survive scoped().
11+
func TestScopedWildcardPassesAllProjects(t *testing.T) {
12+
t1 := time.Date(2026, 4, 23, 10, 0, 0, 0, time.UTC)
13+
chunks := []dashboardChunkRow{
14+
{chunkID: "c1", project: "team-alpha", createdBy: "alice", createdAt: t1,
15+
parsed: parseMustChunk(t, []byte(`{"sessions":[{"id":"s1","project":"team-alpha","started_at":"2026-04-23T08:00:00Z"}],"observations":[],"prompts":[]}`))},
16+
{chunkID: "c2", project: "team-beta", createdBy: "bob", createdAt: t1,
17+
parsed: parseMustChunk(t, []byte(`{"sessions":[{"id":"s2","project":"team-beta","started_at":"2026-04-23T09:00:00Z"}],"observations":[],"prompts":[]}`))},
18+
{chunkID: "c3", project: "other-project", createdBy: "charlie", createdAt: t1,
19+
parsed: parseMustChunk(t, []byte(`{"sessions":[{"id":"s3","project":"other-project","started_at":"2026-04-23T10:00:00Z"}],"observations":[],"prompts":[]}`))},
20+
}
21+
22+
model, err := buildDashboardReadModel(chunks)
23+
if err != nil {
24+
t.Fatalf("buildDashboardReadModel: %v", err)
25+
}
26+
27+
// Wildcard "*" map — represents the wildcard sentinel.
28+
wildcard := map[string]struct{}{"*": {}}
29+
scoped := model.scoped(wildcard)
30+
if len(scoped.projects) != 3 {
31+
t.Fatalf("wildcard allowlist must pass all 3 projects through scoped(), got %d: %v", len(scoped.projects), scoped.projects)
32+
}
33+
}
34+
35+
// TestScopedWithExactAllowlist ensures that scoped() with an explicit list
36+
// (no wildcard) filters the dashboard correctly — backward compatibility guard.
37+
func TestScopedWithExactAllowlist(t *testing.T) {
38+
t1 := time.Date(2026, 4, 23, 10, 0, 0, 0, time.UTC)
39+
chunks := []dashboardChunkRow{
40+
{chunkID: "c1", project: "team-alpha", createdBy: "alice", createdAt: t1,
41+
parsed: parseMustChunk(t, []byte(`{"sessions":[{"id":"s1","project":"team-alpha","started_at":"2026-04-23T08:00:00Z"}],"observations":[],"prompts":[]}`))},
42+
{chunkID: "c2", project: "team-beta", createdBy: "bob", createdAt: t1,
43+
parsed: parseMustChunk(t, []byte(`{"sessions":[{"id":"s2","project":"team-beta","started_at":"2026-04-23T09:00:00Z"}],"observations":[],"prompts":[]}`))},
44+
}
45+
46+
model, err := buildDashboardReadModel(chunks)
47+
if err != nil {
48+
t.Fatalf("buildDashboardReadModel: %v", err)
49+
}
50+
51+
// Explicit list: only "team-alpha" must survive scoped().
52+
scoped := model.scoped(map[string]struct{}{"team-alpha": {}})
53+
if len(scoped.projects) != 1 || scoped.projects[0].Project != "team-alpha" {
54+
t.Fatalf("exact allowlist must keep only team-alpha, got %v", scoped.projects)
55+
}
56+
}
57+
58+
// TestNormalizeDashboardProjectWildcardAllowsAnyProject verifies that with wildcard
59+
// set, any project passes normalizeDashboardProject (no ErrDashboardProjectForbidden).
60+
func TestNormalizeDashboardProjectWildcardAllowsAnyProject(t *testing.T) {
61+
cs := &CloudStore{}
62+
cs.SetDashboardAllowedProjects([]string{"*"})
63+
64+
_, err := cs.normalizeDashboardProject("any-project")
65+
if err != nil && !errors.Is(err, ErrDashboardProjectNotFound) {
66+
// ErrDashboardProjectNotFound is fine (no DB) — ErrDashboardProjectForbidden is not.
67+
if errors.Is(err, ErrDashboardProjectForbidden) {
68+
t.Fatalf("wildcard allowlist must not forbid any project, got %v", err)
69+
}
70+
}
71+
}

0 commit comments

Comments
 (0)