From 0a2ecef6676e34fe75f4f0ba422c676aba2779c2 Mon Sep 17 00:00:00 2001 From: Harmon Date: Fri, 16 Oct 2026 12:19:36 +0800 Subject: [PATCH] Add a read_all scope and explicit permanent tokens An integration token used to be bound to the resource list that existed when it was minted, so every new basemap needed a fresh token. read_all grants read on every basemap and vector, including ones catalogued later, which is what an internal consumer of the whole catalogue actually wants. Tokens also gain a way to opt out of the default lifetime: expires_at accepts never (or 0) in the API and the console, producing a token with no expiry. The console shows blanket access as a badge instead of an empty grant list, and the token form exposes both new options. --- services/api-go/internal/basemap/auth.go | 36 +++++--- .../api-go/internal/basemap/features_test.go | 87 +++++++++++++++++++ services/api-go/internal/basemap/models.go | 4 +- services/api-go/internal/basemap/vectors.go | 3 + .../internal/basemapapi/admin_template.go | 9 +- services/api-go/internal/basemapapi/server.go | 25 +++++- 6 files changed, 145 insertions(+), 19 deletions(-) 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 = `
-
留空走服务端默认值。
-
不勾选则只能读取已授权的底图与矢量。
+
留空走服务端默认有效期;填 never(或 0)则永不过期。
+
不勾选则只能读取下面列出的底图与矢量。
逗号或换行分隔,写底图的 code。
@@ -529,8 +529,9 @@ const adminTemplate = `
创建 {{fmtTime .CreatedAt}}
-
底图:{{tokenGrants .Grants}}
-
矢量:{{vectorTokenGrants .VectorGrants}}
+ {{if hasScope .Scopes "read_all"}}
全部底图与矢量(含新增)
+ {{else}}
底图:{{tokenGrants .Grants}}
+
矢量:{{vectorTokenGrants .VectorGrants}}
{{end}}
能力:{{if .Scopes}}{{range $i, $s := .Scopes}}{{if $i}} · {{end}}{{$s}}{{end}}{{else}}-{{end}}
{{statusText .Status}} diff --git a/services/api-go/internal/basemapapi/server.go b/services/api-go/internal/basemapapi/server.go index c58c507..6240176 100644 --- a/services/api-go/internal/basemapapi/server.go +++ b/services/api-go/internal/basemapapi/server.go @@ -95,6 +95,14 @@ func New(store *basemap.Store, system basemap.SystemInfo, options Options) (*Ser } return strings.Join(parts, ", ") }, + "hasScope": func(scopes []string, want string) bool { + for _, scope := range scopes { + if scope == want { + return true + } + } + return false + }, "vectorTokenGrants": func(grants []basemap.VectorTokenGrant) string { if len(grants) == 0 { return "-" @@ -1118,7 +1126,11 @@ func decodeTokenInput(r *http.Request) (basemap.CreateTokenInput, error) { VectorCodes: payload.VectorCodes, Scopes: payload.Scopes, } - if strings.TrimSpace(payload.ExpiresAt) != "" { + switch strings.ToLower(strings.TrimSpace(payload.ExpiresAt)) { + case "never", "0": + input.NoExpiry = true + case "": + default: parsed, err := time.Parse(time.RFC3339, payload.ExpiresAt) if err != nil { return basemap.CreateTokenInput{}, fmt.Errorf("parse expires_at: %w", err) @@ -1139,9 +1151,14 @@ func decodeTokenInputFromForm(r *http.Request) (basemap.CreateTokenInput, error) Scopes: splitCSV(r.FormValue("scopes")), } if value := strings.TrimSpace(r.FormValue("expires_at")); value != "" { - if minutes, err := strconv.Atoi(value); err == nil && minutes > 0 { - expiresAt := time.Now().UTC().Add(time.Duration(minutes) * time.Minute) - input.ExpiresAt = &expiresAt + switch strings.ToLower(value) { + case "never", "0": + input.NoExpiry = true + default: + if minutes, err := strconv.Atoi(value); err == nil && minutes > 0 { + expiresAt := time.Now().UTC().Add(time.Duration(minutes) * time.Minute) + input.ExpiresAt = &expiresAt + } } } return input, nil