diff --git a/services/api-go/internal/basemap/auth.go b/services/api-go/internal/basemap/auth.go index e6f30b5..1c1e1ef 100644 --- a/services/api-go/internal/basemap/auth.go +++ b/services/api-go/internal/basemap/auth.go @@ -29,14 +29,14 @@ func (s *Store) CreateToken(ctx context.Context, input CreateTokenInput) (Create return CreatedToken{}, errors.New("token name is required") } // A token needs at least one capability: read grants, or the slice scope. - hasSliceScope := false + hasCapability := false for _, value := range input.Scopes { - if strings.EqualFold(strings.TrimSpace(value), slicePermission) { - hasSliceScope = true + if normalized := strings.ToLower(strings.TrimSpace(value)); normalized == slicePermission || normalized == readAllPermission { + hasCapability = true } } - if len(input.BasemapCodes) == 0 && len(input.VectorCodes) == 0 && !hasSliceScope { - return CreatedToken{}, errors.New("at least one basemap code, vector code, or the slice scope is required") + if len(input.BasemapCodes) == 0 && len(input.VectorCodes) == 0 && !hasCapability { + return CreatedToken{}, errors.New("at least one basemap code, vector code, or capability scope is required") } resolvedVectorAssets := map[string]VectorAsset{} @@ -66,7 +66,7 @@ func (s *Store) CreateToken(ctx context.Context, input CreateTokenInput) (Create now := nowUTC() // Fall back to the configured default lifetime so tokens do not live forever // unless an explicit expiry was supplied. - if input.ExpiresAt == nil && s.cfg.TokenDefaultTTL > 0 { + if !input.NoExpiry && input.ExpiresAt == nil && s.cfg.TokenDefaultTTL > 0 { expiresAt := now.Add(s.cfg.TokenDefaultTTL) input.ExpiresAt = &expiresAt } @@ -91,7 +91,7 @@ func (s *Store) CreateToken(ctx context.Context, input CreateTokenInput) (Create } } if len(normalizedCodes) == 0 { - if len(resolvedVectorAssets) == 0 && !hasSliceScope { + if len(resolvedVectorAssets) == 0 && !hasCapability { return CreatedToken{}, errors.New("no valid basemap codes provided") } } @@ -421,11 +421,17 @@ func (s *Store) AuthorizeToken(ctx context.Context, rawToken string) (TokenAuth, // Token scopes are coarse capabilities layered on top of per-resource grants. // Every token can read; "slice" additionally allows submitting tile jobs. -const slicePermission = "slice" +const ( + slicePermission = "slice" + // readAllPermission grants read on every basemap and vector, including ones + // catalogued after the token was issued. + readAllPermission = "read_all" +) var allowedTokenScopes = map[string]struct{}{ - readPermission: {}, - slicePermission: {}, + readPermission: {}, + slicePermission: {}, + readAllPermission: {}, } // normalizeTokenScopes keeps only known scopes and always includes read, so the @@ -466,7 +472,17 @@ func (auth TokenAuth) CanSlice() bool { _, ok := auth.Scopes[slicePermission] return ok } + +// CanReadAll reports whether the token carries blanket read access. +func (auth TokenAuth) CanReadAll() bool { + _, ok := auth.Scopes[readAllPermission] + return ok +} + func (auth TokenAuth) CanReadBasemap(code string, version string) bool { + if auth.CanReadAll() { + return true + } group, ok := auth.Permissions[readPermission] if !ok { return false diff --git a/services/api-go/internal/basemap/features_test.go b/services/api-go/internal/basemap/features_test.go index 658d759..f3f8de9 100644 --- a/services/api-go/internal/basemap/features_test.go +++ b/services/api-go/internal/basemap/features_test.go @@ -870,3 +870,90 @@ func TestTileJobSingleSourceSkipsMosaic(t *testing.T) { t.Fatalf("expected the single source to slice directly, got %q (%s)", jobs[0].Status, jobs[0].Message) } } + +func TestReadAllScopeCoversNewResourcesAndNeverExpires(t *testing.T) { + ctx := context.Background() + root := t.TempDir() + tileRoot := filepath.Join(root, "tiles") + vectorRoot := filepath.Join(root, "vectors") + writeTestFile(t, filepath.Join(tileRoot, "first", "0", "0", "0.webp"), "tile") + + store := openTestStore(t, StoreConfig{ + SQLitePath: filepath.Join(root, "basemap.db"), + ScanRoot: tileRoot, + VectorScanRoot: vectorRoot, + APIBaseURL: "http://127.0.0.1:8910", + TileBaseURL: "http://127.0.0.1:8910", + VectorBaseURL: "http://127.0.0.1:8910", + TokenDefaultTTL: time.Hour, + }) + if _, err := store.RunScan(ctx, defaultScanSourceCode); err != nil { + t.Fatalf("scan: %v", err) + } + + // No grants at all, and NoExpiry instead of the default hour. + created, err := store.CreateToken(ctx, CreateTokenInput{ + Name: "insar", + Scopes: []string{"read_all"}, + NoExpiry: true, + }) + if err != nil { + t.Fatalf("create read_all token: %v", err) + } + if created.Meta.ExpiresAt != nil { + t.Fatalf("expected no expiry, got %v", created.Meta.ExpiresAt) + } + + auth, err := store.AuthorizeToken(ctx, created.Token) + if err != nil { + t.Fatalf("authorize: %v", err) + } + if !auth.CanReadAll() { + t.Fatal("expected the token to report blanket read access") + } + if !auth.CanReadBasemap("first", "current") { + t.Fatal("expected read_all to reach an existing basemap") + } + + // A basemap catalogued after the token was issued must be reachable too; + // that is the whole point of read_all. + writeTestFile(t, filepath.Join(tileRoot, "second", "0", "0", "0.webp"), "tile") + if _, err := store.RunScan(ctx, defaultScanSourceCode); err != nil { + t.Fatalf("rescan: %v", err) + } + if !auth.CanReadBasemap("second", "current") { + t.Fatal("expected read_all to cover a basemap added later") + } + + // Vectors behave the same way. + writeTestFile(t, filepath.Join(vectorRoot, "later.geojson"), `{"type":"FeatureCollection","features":[]}`) + if _, err := store.RunVectorScan(ctx); err != nil { + t.Fatalf("vector scan: %v", err) + } + assets, err := store.ListVectorAssets(ctx) + if err != nil || len(assets) != 1 { + t.Fatalf("expected one vector asset, got %d (%v)", len(assets), err) + } + if !auth.CanReadVector(assets[0].Code) { + t.Fatal("expected read_all to cover a vector added later") + } + + // A plain read token still needs grants, so the scope is not accidental. + plain, err := store.CreateToken(ctx, CreateTokenInput{ + Name: "plain", + BasemapCodes: []string{"first"}, + }) + if err != nil { + t.Fatalf("create plain token: %v", err) + } + plainAuth, err := store.AuthorizeToken(ctx, plain.Token) + if err != nil { + t.Fatalf("authorize plain: %v", err) + } + if plainAuth.CanReadBasemap("second", "current") { + t.Fatal("a granted token must not pick up basemaps it was not granted") + } + if plainAuth.CanReadAll() { + t.Fatal("a plain token must not report blanket access") + } +} diff --git a/services/api-go/internal/basemap/models.go b/services/api-go/internal/basemap/models.go index 6d15b73..239b944 100644 --- a/services/api-go/internal/basemap/models.go +++ b/services/api-go/internal/basemap/models.go @@ -212,7 +212,9 @@ type CreateTokenInput struct { BasemapCodes []string VectorCodes []string Scopes []string - ExpiresAt *time.Time + // NoExpiry creates a token that never expires, overriding the default TTL. + NoExpiry bool + ExpiresAt *time.Time } type TokenAuth struct { diff --git a/services/api-go/internal/basemap/vectors.go b/services/api-go/internal/basemap/vectors.go index b93e0c1..88fad88 100644 --- a/services/api-go/internal/basemap/vectors.go +++ b/services/api-go/internal/basemap/vectors.go @@ -584,6 +584,9 @@ func (s *Store) buildVectorDownloadURL(code string) string { } func (auth TokenAuth) CanReadVector(code string) bool { + if auth.CanReadAll() { + return true + } group, ok := auth.VectorPermissions[readPermission] if !ok { return false diff --git a/services/api-go/internal/basemapapi/admin_template.go b/services/api-go/internal/basemapapi/admin_template.go index 087280d..195a0ee 100644 --- a/services/api-go/internal/basemapapi/admin_template.go +++ b/services/api-go/internal/basemapapi/admin_template.go @@ -502,8 +502,8 @@ const adminTemplate = `