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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user