mirror of
https://github.com/juanfont/headscale.git
synced 2026-09-17 22:12:03 +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
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package scope
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"pgregory.net/rapid"
|
||||
)
|
||||
|
||||
// scopeGen draws a scope: mostly from the real vocabulary, sometimes adversarial
|
||||
// junk (including read-like junk such as "foo:read") so the rules are exercised
|
||||
// against unknown input too.
|
||||
func scopeGen() *rapid.Generator[Scope] {
|
||||
known := Known()
|
||||
|
||||
return rapid.Custom(func(t *rapid.T) Scope {
|
||||
if rapid.Float64().Draw(t, "junkP") < 0.2 {
|
||||
return Scope(rapid.StringMatching(`[a-z_]{1,12}(:read)?`).Draw(t, "junk"))
|
||||
}
|
||||
|
||||
return rapid.SampledFrom(known).Draw(t, "vocab")
|
||||
})
|
||||
}
|
||||
|
||||
// TestGrantsMatchesOracle fuzzes Grants against the independent oracle over random
|
||||
// granted-sets and want-scopes (vocabulary + junk).
|
||||
func TestGrantsMatchesOracle(t *testing.T) {
|
||||
rapid.Check(t, func(rt *rapid.T) {
|
||||
granted := rapid.SliceOfN(scopeGen(), 0, 6).Draw(rt, "granted")
|
||||
want := scopeGen().Draw(rt, "want")
|
||||
|
||||
if got, exp := Grants(granted, want), oracleGrants(granted, want); got != exp {
|
||||
rt.Fatalf("Grants(%v, %q) = %v, oracle = %v", granted, want, got, exp)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestGrantsInvariants asserts the algebraic properties of the grant relation hold
|
||||
// for arbitrary inputs.
|
||||
func TestGrantsInvariants(t *testing.T) {
|
||||
rapid.Check(t, func(rt *rapid.T) {
|
||||
granted := rapid.SliceOfN(scopeGen(), 0, 6).Draw(rt, "granted")
|
||||
want := scopeGen().Draw(rt, "want")
|
||||
|
||||
// Reflexivity: a scope always grants itself.
|
||||
if !Grants([]Scope{want}, want) {
|
||||
rt.Fatalf("reflexivity: %q does not grant itself", want)
|
||||
}
|
||||
|
||||
// The empty set grants nothing.
|
||||
if Grants(nil, want) {
|
||||
rt.Fatalf("empty grant satisfied %q", want)
|
||||
}
|
||||
|
||||
// OR-semantics: a set grants iff some member does.
|
||||
anyMember := slices.ContainsFunc(granted, func(g Scope) bool {
|
||||
return Grants([]Scope{g}, want)
|
||||
})
|
||||
if Grants(granted, want) != anyMember {
|
||||
rt.Fatalf("OR-semantics broken for %v / %q", granted, want)
|
||||
}
|
||||
|
||||
// Monotonicity: adding a scope never withdraws a grant.
|
||||
before := Grants(granted, want)
|
||||
extra := scopeGen().Draw(rt, "extra")
|
||||
after := Grants(append(slices.Clone(granted), extra), want)
|
||||
|
||||
if before && !after {
|
||||
rt.Fatalf("monotonicity broken: adding %q withdrew the grant of %q", extra, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSuperScopeProperties fuzzes the super-scope rules.
|
||||
func TestSuperScopeProperties(t *testing.T) {
|
||||
rapid.Check(t, func(rt *rapid.T) {
|
||||
want := scopeGen().Draw(rt, "want")
|
||||
|
||||
// all grants everything.
|
||||
if !Grants([]Scope{All}, want) {
|
||||
rt.Fatalf("all did not grant %q", want)
|
||||
}
|
||||
|
||||
// all:read grants exactly the read scopes.
|
||||
if Grants([]Scope{AllRead}, want) != want.IsRead() {
|
||||
rt.Fatalf("all:read grant of %q = %v, want IsRead = %v",
|
||||
want, Grants([]Scope{AllRead}, want), want.IsRead())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package scope
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// classify decomposes a scope into (resource, super, read) WITHOUT reusing any of
|
||||
// the production logic, so the oracle below is an independent second
|
||||
// implementation of the grant rule: a divergence between it and Grants is a real
|
||||
// bug in one of them, not a tautology.
|
||||
type classified struct {
|
||||
resource string // "" for super-scopes; otherwise the write-scope base (e.g. "auth_keys")
|
||||
super bool // all / all:read
|
||||
read bool // the :read variant
|
||||
}
|
||||
|
||||
func classify(s Scope) classified {
|
||||
str := string(s)
|
||||
read := strings.HasSuffix(str, ":read")
|
||||
base := strings.TrimSuffix(str, ":read")
|
||||
|
||||
if base == "all" {
|
||||
return classified{super: true, read: read}
|
||||
}
|
||||
|
||||
return classified{resource: base, read: read}
|
||||
}
|
||||
|
||||
// oracle re-derives "does have satisfy want" from the classification, independent
|
||||
// of satisfies/Grants.
|
||||
func oracle(have, want Scope) bool {
|
||||
h, w := classify(have), classify(want)
|
||||
|
||||
if h.super {
|
||||
// "all" grants everything; "all:read" grants only reads.
|
||||
return !h.read || w.read
|
||||
}
|
||||
|
||||
if h.resource != w.resource {
|
||||
return false
|
||||
}
|
||||
|
||||
// Same resource: a write scope grants both read and write; a read scope grants
|
||||
// only read.
|
||||
return !h.read || w.read
|
||||
}
|
||||
|
||||
func oracleGrants(granted []Scope, want Scope) bool {
|
||||
for _, g := range granted {
|
||||
if oracle(g, want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// TestGrantsHandPicked pins specific (granted, want) outcomes with literal
|
||||
// expected values, independent of any oracle: the anchor for the rules.
|
||||
func TestGrantsHandPicked(t *testing.T) {
|
||||
tests := []struct {
|
||||
granted []Scope
|
||||
want Scope
|
||||
ok bool
|
||||
}{
|
||||
{granted: []Scope{AuthKeys}, want: AuthKeys, ok: true},
|
||||
{granted: []Scope{AuthKeys}, want: AuthKeysRead, ok: true},
|
||||
{granted: []Scope{AuthKeysRead}, want: AuthKeys, ok: false},
|
||||
{granted: []Scope{AuthKeysRead}, want: AuthKeysRead, ok: true},
|
||||
{granted: []Scope{DevicesCore}, want: AuthKeys, ok: false},
|
||||
{granted: []Scope{DevicesCoreRead}, want: AuthKeysRead, ok: false},
|
||||
{granted: []Scope{All}, want: AuthKeys, ok: true},
|
||||
{granted: []Scope{All}, want: FeatureSettingsRead, ok: true},
|
||||
{granted: []Scope{AllRead}, want: PolicyFileRead, ok: true},
|
||||
{granted: []Scope{AllRead}, want: PolicyFile, ok: false},
|
||||
{granted: []Scope{AllRead}, want: All, ok: false},
|
||||
{granted: []Scope{DevicesCore, OAuthKeys}, want: OAuthKeys, ok: true},
|
||||
{granted: nil, want: AuthKeysRead, ok: false},
|
||||
{granted: []Scope{"garbage"}, want: AuthKeys, ok: false},
|
||||
{granted: []Scope{"garbage"}, want: "garbage", ok: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(fmt.Sprintf("%v_%s", tt.granted, tt.want), func(t *testing.T) {
|
||||
if got := Grants(tt.granted, tt.want); got != tt.ok {
|
||||
t.Errorf("Grants(%v, %q) = %v, want %v", tt.granted, tt.want, got, tt.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantsExhaustive checks every single-grant pair in the vocabulary against
|
||||
// the independent oracle, plus representative multi-grant cases.
|
||||
func TestGrantsExhaustive(t *testing.T) {
|
||||
known := Known()
|
||||
|
||||
for _, g := range known {
|
||||
for _, w := range known {
|
||||
got := Grants([]Scope{g}, w)
|
||||
exp := oracle(g, w)
|
||||
|
||||
if got != exp {
|
||||
t.Errorf("Grants([%q], %q) = %v, oracle = %v", g, w, got, exp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
multi := [][]Scope{
|
||||
{All, AuthKeysRead},
|
||||
{AllRead, AuthKeys},
|
||||
{AuthKeys, OAuthKeysRead},
|
||||
{DevicesCore, DevicesRoutes, PolicyFile},
|
||||
{AuthKeys, AuthKeys}, // duplicates
|
||||
}
|
||||
|
||||
for _, granted := range multi {
|
||||
for _, w := range known {
|
||||
got := Grants(granted, w)
|
||||
exp := oracleGrants(granted, w)
|
||||
|
||||
if got != exp {
|
||||
t.Errorf("Grants(%v, %q) = %v, oracle = %v", granted, w, got, exp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteGrantsItsRead and friends assert the structural rules over the whole
|
||||
// vocabulary, deterministically.
|
||||
func TestWriteGrantsItsRead(t *testing.T) {
|
||||
for _, s := range Known() {
|
||||
if !s.IsWrite() {
|
||||
continue
|
||||
}
|
||||
|
||||
read := Scope(string(s) + ":read")
|
||||
if !Grants([]Scope{s}, read) {
|
||||
t.Errorf("write scope %q does not grant its read subset %q", s, read)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadNeverGrantsWrite(t *testing.T) {
|
||||
for _, s := range Known() {
|
||||
if !s.IsRead() {
|
||||
continue
|
||||
}
|
||||
|
||||
write := Scope(strings.TrimSuffix(string(s), ":read"))
|
||||
if Grants([]Scope{s}, write) {
|
||||
t.Errorf("read scope %q must not grant write scope %q", s, write)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllGrantsEverything(t *testing.T) {
|
||||
for _, w := range Known() {
|
||||
if !Grants([]Scope{All}, w) {
|
||||
t.Errorf("all should grant %q", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllReadGrantsReadsOnly(t *testing.T) {
|
||||
for _, w := range Known() {
|
||||
got := Grants([]Scope{AllRead}, w)
|
||||
if got != w.IsRead() {
|
||||
t.Errorf("all:read grants %q = %v, want %v (IsRead)", w, got, w.IsRead())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResourceIsolation: a non-super scope never grants a scope of a different
|
||||
// resource.
|
||||
func TestResourceIsolation(t *testing.T) {
|
||||
for _, a := range Known() {
|
||||
if a == All || a == AllRead {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, b := range Known() {
|
||||
if classify(a).resource == classify(b).resource {
|
||||
continue
|
||||
}
|
||||
|
||||
if Grants([]Scope{a}, b) {
|
||||
t.Errorf("scope %q (resource %q) must not grant %q (resource %q)",
|
||||
a, classify(a).resource, b, classify(b).resource)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiresTags(t *testing.T) {
|
||||
tests := []struct {
|
||||
scopes []Scope
|
||||
requires bool
|
||||
}{
|
||||
{scopes: []Scope{DevicesCore}, requires: true},
|
||||
{scopes: []Scope{AuthKeys}, requires: true},
|
||||
{scopes: []Scope{OAuthKeys}, requires: false},
|
||||
{scopes: []Scope{PolicyFile, AuthKeys}, requires: true},
|
||||
{scopes: []Scope{DevicesCoreRead}, requires: false},
|
||||
{scopes: nil, requires: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(fmt.Sprintf("%v", tt.scopes), func(t *testing.T) {
|
||||
if got := RequiresTags(tt.scopes); got != tt.requires {
|
||||
t.Errorf("RequiresTags(%v) = %v, want %v", tt.scopes, got, tt.requires)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnownIsComplete(t *testing.T) {
|
||||
known := Known()
|
||||
|
||||
seen := make(map[Scope]bool, len(known))
|
||||
for _, s := range known {
|
||||
if seen[s] {
|
||||
t.Errorf("Known() contains duplicate %q", s)
|
||||
}
|
||||
|
||||
seen[s] = true
|
||||
}
|
||||
|
||||
// 7 resources × 2 (write+read) + 2 super-scopes = 16.
|
||||
if len(known) != 16 {
|
||||
t.Errorf("Known() has %d scopes, want 16", len(known))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user