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")
|
return CreatedToken{}, errors.New("token name is required")
|
||||||
}
|
}
|
||||||
// A token needs at least one capability: read grants, or the slice scope.
|
// A token needs at least one capability: read grants, or the slice scope.
|
||||||
hasSliceScope := false
|
hasCapability := false
|
||||||
for _, value := range input.Scopes {
|
for _, value := range input.Scopes {
|
||||||
if strings.EqualFold(strings.TrimSpace(value), slicePermission) {
|
if normalized := strings.ToLower(strings.TrimSpace(value)); normalized == slicePermission || normalized == readAllPermission {
|
||||||
hasSliceScope = true
|
hasCapability = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(input.BasemapCodes) == 0 && len(input.VectorCodes) == 0 && !hasSliceScope {
|
if len(input.BasemapCodes) == 0 && len(input.VectorCodes) == 0 && !hasCapability {
|
||||||
return CreatedToken{}, errors.New("at least one basemap code, vector code, or the slice scope is required")
|
return CreatedToken{}, errors.New("at least one basemap code, vector code, or capability scope is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
resolvedVectorAssets := map[string]VectorAsset{}
|
resolvedVectorAssets := map[string]VectorAsset{}
|
||||||
@@ -66,7 +66,7 @@ func (s *Store) CreateToken(ctx context.Context, input CreateTokenInput) (Create
|
|||||||
now := nowUTC()
|
now := nowUTC()
|
||||||
// Fall back to the configured default lifetime so tokens do not live forever
|
// Fall back to the configured default lifetime so tokens do not live forever
|
||||||
// unless an explicit expiry was supplied.
|
// 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)
|
expiresAt := now.Add(s.cfg.TokenDefaultTTL)
|
||||||
input.ExpiresAt = &expiresAt
|
input.ExpiresAt = &expiresAt
|
||||||
}
|
}
|
||||||
@@ -91,7 +91,7 @@ func (s *Store) CreateToken(ctx context.Context, input CreateTokenInput) (Create
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(normalizedCodes) == 0 {
|
if len(normalizedCodes) == 0 {
|
||||||
if len(resolvedVectorAssets) == 0 && !hasSliceScope {
|
if len(resolvedVectorAssets) == 0 && !hasCapability {
|
||||||
return CreatedToken{}, errors.New("no valid basemap codes provided")
|
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.
|
// Token scopes are coarse capabilities layered on top of per-resource grants.
|
||||||
// Every token can read; "slice" additionally allows submitting tile jobs.
|
// 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{}{
|
var allowedTokenScopes = map[string]struct{}{
|
||||||
readPermission: {},
|
readPermission: {},
|
||||||
slicePermission: {},
|
slicePermission: {},
|
||||||
|
readAllPermission: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeTokenScopes keeps only known scopes and always includes read, so the
|
// normalizeTokenScopes keeps only known scopes and always includes read, so the
|
||||||
@@ -466,7 +472,17 @@ func (auth TokenAuth) CanSlice() bool {
|
|||||||
_, ok := auth.Scopes[slicePermission]
|
_, ok := auth.Scopes[slicePermission]
|
||||||
return ok
|
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 {
|
func (auth TokenAuth) CanReadBasemap(code string, version string) bool {
|
||||||
|
if auth.CanReadAll() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
group, ok := auth.Permissions[readPermission]
|
group, ok := auth.Permissions[readPermission]
|
||||||
if !ok {
|
if !ok {
|
||||||
return false
|
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)
|
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,6 +212,8 @@ type CreateTokenInput struct {
|
|||||||
BasemapCodes []string
|
BasemapCodes []string
|
||||||
VectorCodes []string
|
VectorCodes []string
|
||||||
Scopes []string
|
Scopes []string
|
||||||
|
// NoExpiry creates a token that never expires, overriding the default TTL.
|
||||||
|
NoExpiry bool
|
||||||
ExpiresAt *time.Time
|
ExpiresAt *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -584,6 +584,9 @@ func (s *Store) buildVectorDownloadURL(code string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (auth TokenAuth) CanReadVector(code string) bool {
|
func (auth TokenAuth) CanReadVector(code string) bool {
|
||||||
|
if auth.CanReadAll() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
group, ok := auth.VectorPermissions[readPermission]
|
group, ok := auth.VectorPermissions[readPermission]
|
||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -502,8 +502,8 @@ const adminTemplate = `<!DOCTYPE html>
|
|||||||
<form class="stack" method="post" action="/admin/tokens">
|
<form class="stack" method="post" action="/admin/tokens">
|
||||||
<div class="grid-form">
|
<div class="grid-form">
|
||||||
<div><label>令牌名称</label><input name="name" placeholder="viewer-a" required></div>
|
<div><label>令牌名称</label><input name="name" placeholder="viewer-a" required></div>
|
||||||
<div><label>有效时长(分钟)</label><input name="expires_at" inputmode="numeric" placeholder="留空使用默认有效期"><div class="field-note">留空走服务端默认值。</div></div>
|
<div><label>有效时长(分钟)</label><input name="expires_at" placeholder="留空用默认;填 never 表示永久"><div class="field-note">留空走服务端默认有效期;填 <b>never</b>(或 0)则永不过期。</div></div>
|
||||||
<div><label>附加能力</label><label class="check"><input type="checkbox" name="scopes" value="slice"> 允许提交切片任务</label><div class="field-note">不勾选则只能读取已授权的底图与矢量。</div></div>
|
<div><label>附加能力</label><label class="check"><input type="checkbox" name="scopes" value="read_all"> 全权限读取(含以后新增的图层)</label><label class="check"><input type="checkbox" name="scopes" value="slice"> 允许提交切片任务</label><div class="field-note">不勾选则只能读取下面列出的底图与矢量。</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid-form">
|
<div class="grid-form">
|
||||||
<div><label>底图编码</label><textarea name="basemap_codes" placeholder="gaode-image, google-image"></textarea><div class="field-note">逗号或换行分隔,写底图的 code。</div></div>
|
<div><label>底图编码</label><textarea name="basemap_codes" placeholder="gaode-image, google-image"></textarea><div class="field-note">逗号或换行分隔,写底图的 code。</div></div>
|
||||||
@@ -529,8 +529,9 @@ const adminTemplate = `<!DOCTYPE html>
|
|||||||
<div class="small muted">创建 {{fmtTime .CreatedAt}}</div>
|
<div class="small muted">创建 {{fmtTime .CreatedAt}}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="small">
|
<td class="small">
|
||||||
<div><span class="muted">底图:</span>{{tokenGrants .Grants}}</div>
|
{{if hasScope .Scopes "read_all"}}<div><span class="badge success">全部底图与矢量(含新增)</span></div>
|
||||||
<div style="margin-top:4px"><span class="muted">矢量:</span>{{vectorTokenGrants .VectorGrants}}</div>
|
{{else}}<div><span class="muted">底图:</span>{{tokenGrants .Grants}}</div>
|
||||||
|
<div style="margin-top:4px"><span class="muted">矢量:</span>{{vectorTokenGrants .VectorGrants}}</div>{{end}}
|
||||||
<div style="margin-top:4px"><span class="muted">能力:</span>{{if .Scopes}}{{range $i, $s := .Scopes}}{{if $i}} · {{end}}{{$s}}{{end}}{{else}}-{{end}}</div>
|
<div style="margin-top:4px"><span class="muted">能力:</span>{{if .Scopes}}{{range $i, $s := .Scopes}}{{if $i}} · {{end}}{{$s}}{{end}}{{else}}-{{end}}</div>
|
||||||
</td>
|
</td>
|
||||||
<td><span class="badge {{statusTone .Status}}">{{statusText .Status}}</span></td>
|
<td><span class="badge {{statusTone .Status}}">{{statusText .Status}}</span></td>
|
||||||
|
|||||||
@@ -95,6 +95,14 @@ func New(store *basemap.Store, system basemap.SystemInfo, options Options) (*Ser
|
|||||||
}
|
}
|
||||||
return strings.Join(parts, ", ")
|
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 {
|
"vectorTokenGrants": func(grants []basemap.VectorTokenGrant) string {
|
||||||
if len(grants) == 0 {
|
if len(grants) == 0 {
|
||||||
return "-"
|
return "-"
|
||||||
@@ -1118,7 +1126,11 @@ func decodeTokenInput(r *http.Request) (basemap.CreateTokenInput, error) {
|
|||||||
VectorCodes: payload.VectorCodes,
|
VectorCodes: payload.VectorCodes,
|
||||||
Scopes: payload.Scopes,
|
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)
|
parsed, err := time.Parse(time.RFC3339, payload.ExpiresAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return basemap.CreateTokenInput{}, fmt.Errorf("parse expires_at: %w", err)
|
return basemap.CreateTokenInput{}, fmt.Errorf("parse expires_at: %w", err)
|
||||||
@@ -1139,11 +1151,16 @@ func decodeTokenInputFromForm(r *http.Request) (basemap.CreateTokenInput, error)
|
|||||||
Scopes: splitCSV(r.FormValue("scopes")),
|
Scopes: splitCSV(r.FormValue("scopes")),
|
||||||
}
|
}
|
||||||
if value := strings.TrimSpace(r.FormValue("expires_at")); value != "" {
|
if value := strings.TrimSpace(r.FormValue("expires_at")); value != "" {
|
||||||
|
switch strings.ToLower(value) {
|
||||||
|
case "never", "0":
|
||||||
|
input.NoExpiry = true
|
||||||
|
default:
|
||||||
if minutes, err := strconv.Atoi(value); err == nil && minutes > 0 {
|
if minutes, err := strconv.Atoi(value); err == nil && minutes > 0 {
|
||||||
expiresAt := time.Now().UTC().Add(time.Duration(minutes) * time.Minute)
|
expiresAt := time.Now().UTC().Add(time.Duration(minutes) * time.Minute)
|
||||||
input.ExpiresAt = &expiresAt
|
input.ExpiresAt = &expiresAt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return input, nil
|
return input, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user