mirror of
https://github.com/juanfont/headscale.git
synced 2026-09-19 06:44:58 +09:00
hscontrol: add the OAuth client and access-token model
Add the OAuth client type, its database storage, the scope grant package, policy tag-ownership exposure, and the state operations backing the v2 OAuth client-credentials flow.
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
// Package scope models the OAuth capability scopes the Headscale v2 API enforces
|
||||
// and the rule for whether a granted set of scopes satisfies a required one.
|
||||
//
|
||||
// The vocabulary is taken from Tailscale's OpenAPI spec (the same scope names
|
||||
// the Terraform provider and Kubernetes operator request), so a client written
|
||||
// against Tailscale's scopes works unchanged against Headscale. The grant
|
||||
// predicate is kept here, separate from the HTTP/huma layer in
|
||||
// hscontrol/api/v2, so it can be tested exhaustively on its own.
|
||||
package scope
|
||||
|
||||
import "strings"
|
||||
|
||||
// Scope is an OAuth capability an operation requires and a token grants. The names
|
||||
// mirror Tailscale's API scopes; a "...:read" scope is the read-only subset of its
|
||||
// write scope.
|
||||
type Scope string
|
||||
|
||||
const (
|
||||
// All and AllRead are Tailscale's forward-compatible super-scopes: "all"
|
||||
// grants every other scope, "all:read" grants every :read subset.
|
||||
All Scope = "all"
|
||||
AllRead Scope = "all:read"
|
||||
|
||||
AuthKeys Scope = "auth_keys"
|
||||
AuthKeysRead Scope = "auth_keys:read"
|
||||
|
||||
// OAuthKeys gates managing OAuth clients (keyType:"client" on the keys
|
||||
// resource).
|
||||
OAuthKeys Scope = "oauth_keys"
|
||||
OAuthKeysRead Scope = "oauth_keys:read"
|
||||
|
||||
DevicesCore Scope = "devices:core"
|
||||
DevicesCoreRead Scope = "devices:core:read"
|
||||
|
||||
DevicesRoutes Scope = "devices:routes"
|
||||
DevicesRoutesRead Scope = "devices:routes:read"
|
||||
|
||||
PolicyFile Scope = "policy_file"
|
||||
PolicyFileRead Scope = "policy_file:read"
|
||||
|
||||
FeatureSettings Scope = "feature_settings"
|
||||
FeatureSettingsRead Scope = "feature_settings:read"
|
||||
)
|
||||
|
||||
const readSuffix = ":read"
|
||||
|
||||
// Known returns every scope in the vocabulary, in a stable order. Useful for
|
||||
// exhaustive iteration in tests and documentation.
|
||||
func Known() []Scope {
|
||||
return []Scope{
|
||||
All, AllRead,
|
||||
AuthKeys, AuthKeysRead,
|
||||
OAuthKeys, OAuthKeysRead,
|
||||
DevicesCore, DevicesCoreRead,
|
||||
DevicesRoutes, DevicesRoutesRead,
|
||||
PolicyFile, PolicyFileRead,
|
||||
FeatureSettings, FeatureSettingsRead,
|
||||
}
|
||||
}
|
||||
|
||||
// IsRead reports whether s is a read-only scope (its name ends with ":read").
|
||||
func (s Scope) IsRead() bool {
|
||||
return strings.HasSuffix(string(s), readSuffix)
|
||||
}
|
||||
|
||||
// IsWrite reports whether s is a non-empty write scope.
|
||||
func (s Scope) IsWrite() bool {
|
||||
return s != "" && !s.IsRead()
|
||||
}
|
||||
|
||||
// Parse converts scope strings (as stored on a token or client) into Scope values.
|
||||
// Unknown strings are kept verbatim; they simply never satisfy any required scope.
|
||||
func Parse(ss []string) []Scope {
|
||||
out := make([]Scope, len(ss))
|
||||
for i, s := range ss {
|
||||
out[i] = Scope(s)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// Grants reports whether the granted scopes satisfy the required want scope.
|
||||
func Grants(granted []Scope, want Scope) bool {
|
||||
for _, g := range granted {
|
||||
if satisfies(g, want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// satisfies reports whether a single held scope satisfies want: exact match; a
|
||||
// write scope grants its own :read subset; "all" grants everything; "all:read"
|
||||
// grants any :read scope.
|
||||
func satisfies(have, want Scope) bool {
|
||||
if have == want || have == All {
|
||||
return true
|
||||
}
|
||||
|
||||
if have == AllRead {
|
||||
return want.IsRead()
|
||||
}
|
||||
|
||||
// A write scope grants its own read subset, e.g. auth_keys ⊇ auth_keys:read.
|
||||
return string(want) == string(have)+readSuffix
|
||||
}
|
||||
|
||||
// RequiresTags reports whether any scope obliges a credential to carry tags:
|
||||
// devices:core and auth_keys mint tagged, tailnet-owned credentials.
|
||||
func RequiresTags(scopes []Scope) bool {
|
||||
for _, s := range scopes {
|
||||
if s == DevicesCore || s == AuthKeys {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user