api/v2: add OAuth client-credentials auth and scope enforcement

This commit is contained in:
Kristoffer Dalby
2026-06-21 17:35:32 +00:00
parent 3fdc068c8c
commit 87555ce621
12 changed files with 708 additions and 156 deletions
+48 -11
View File
@@ -1,7 +1,7 @@
# API v2 Headscale's v2 API
# API v2: Headscale's v2 API
This is Headscale's v2 HTTP API, served at `/api/v2`. Some of its endpoints are
**ported from Tailscale's API** reusing Tailscale's wire shapes so the
**ported from Tailscale's API**, reusing Tailscale's wire shapes, so the
Tailscale ecosystem that cannot talk to Headscale today works: the
[Terraform/OpenTofu provider], [tscli], and the official [Go client]
(`tailscale.com/client/tailscale/v2`).
@@ -23,12 +23,20 @@ headscale's own conventions. The headscale-native admin API stays at `/api/v1`
- Errors use **Tailscale's** body (`{"message","data","status"}`), installed as
a per-API transform (`tailscaleErrorTransformer` in `errors.go`). A future
headscale-native v2 operation would keep Huma's RFC 9457 problem+json.
- Auth accepts the API key as **HTTP Basic** (key as username what the SDK
sends) or **Bearer**. See `authMiddleware`.
- Each operation declares the Tailscale scope it would require (`auth_keys`,
`devices:core`, `devices:routes`, `policy_file`, `feature_settings`, each with
a `:read` subset). Nothing is enforced yet — every key is all-access — pending
OAuth tokens; see the `TODO(scopes)` in `api.go`.
- Auth accepts a credential as **HTTP Basic** (key as username, what the SDK
sends) or **Bearer**: an admin API key (`hskey-api-…`), or an OAuth access
token (`hskey-oauthtok-…`). See `authMiddleware`.
- Each operation declares the Tailscale scope it requires (`auth_keys`,
`oauth_keys`, `devices:core`, `devices:routes`, `policy_file`,
`feature_settings`, each with a `:read` subset, plus `all`/`all:read`).
`requireScope` records it both for the middleware and in the generated
OpenAPI, as an `x-required-scope` extension and a sentence in the operation
description, so the scope shows up in the docs and spec. Enforcement: an
**admin API key is all-access** (scope checks skipped); an **OAuth access
token is scope-limited**, the middleware checks the operation's declared scope
against the token's grant (`scope.Grants`, where a write scope subsumes its
`:read` and `all`/`all:read` are super-scopes). The two are told apart by
credential prefix.
- Resolve one entity by id with a typed getter (`GetNodeByID`, `GetUserByID`,
`GetAPIKeyByID`, `GetPreAuthKeyByID`); add one to state/db if it is missing
rather than scanning a `List`. Build responses from the view accessors
@@ -38,6 +46,35 @@ headscale's own conventions. The headscale-native admin API stays at `/api/v1`
`ExpirySeconds *time.Duration` marshals as nanoseconds, which the spec and
every client read as seconds.
## OAuth clients & scopes
Most of the Tailscale ecosystem (the Terraform provider, `tscli`, the Go client)
accepts **either** an API key **or OAuth 2.0 client-credentials**; the Kubernetes
operator is OAuth-only. Supporting OAuth lets all of them drive Headscale.
- **OAuth clients** are not a separate resource; they are `keyType:"client"` on
the keys endpoint, exactly as Tailscale does it. Create
(`POST /api/v2/tailnet/-/keys` with `{"keyType":"client","scopes":[…],"tags":[…]}`)
returns a `Key` whose `id` is the client id and whose `key` is the secret,
**shown once**; get/list never re-expose it. The secret is
`hskey-client-<clientID>-<secret>`, embedding the client id so the token
endpoint derives it from the secret (Tailscale's `get-authkey` trick). See
`keys.go` (`createOAuthClient`) and `db/oauth.go`.
- **Token endpoint** `POST /api/v2/oauth/token` (`oauth.go`) is a plain handler,
not a Huma operation: it takes `application/x-www-form-urlencoded` and emits
RFC 6749 OAuth2 error bodies (`{"error","error_description"}`). Credentials
arrive in the body or HTTP Basic; optional space-delimited `scope`/`tags`
narrow the token to a subset of the client's grant. It returns a 1-hour
`Bearer` access token (`hskey-oauthtok-…`).
- **Scope enforcement** is the one seam in `authMiddleware`. **Tag enforcement**:
an auth key minted by a token may only carry tags the token holds, or tags
owned-by them via the policy `tagOwners` (`State.TagOwnedByTags`
`policy/v2`), so e.g. an operator token tagged `tag:k8s-operator` may mint
`tag:k8s` keys.
- Credentials/tokens are stored like API keys: a public id/prefix plus an
**Argon2id** hash of the secret (no JWT, no signing keys). `OAuthClient` and
`OAuthAccessToken` live in `types/oauth.go` and `db/oauth.go`.
## Adding an endpoint
Worked example: the keys resource (`keys.go`) = Tailscale auth keys = Headscale
@@ -55,7 +92,7 @@ pre-auth keys.
3. **Map to Headscale.** Write the field ↔ field ↔ `state` call mapping. Record
gaps and the decision for each (e.g. Tailscale `preauthorized` has no
Headscale equivalent accepted, ignored, echoed back). _Acceptance: every
Headscale equivalent: accepted, ignored, echoed back). _Acceptance: every
request field is consumed or deliberately ignored; every response field has a
source._
@@ -73,13 +110,13 @@ compat`; declare its `Errors`; enforce the tailnet and scope. Map state
6. **Roundtrip the real clients.** Add a `t.Run` subtest to `TestAPIv2`
(`hscontrol/servertest/apiv2_test.go`) for each of the Go client, tscli, and
OpenTofu full create→read→list→delete against one shared server on a real
OpenTofu, full create→read→list→delete against one shared server on a real
loopback port (`servertest.WithRealListener`). tscli and tofu come from the
nix dev shell; a missing binary fails the test. _Acceptance: `nix develop -c
go test ./hscontrol/servertest/ -run TestAPIv2` is green._
7. **Update the CLI** only if the v2 operation fully replaces a v1 one. Tailscale
has no separate key-expire verb its `DELETE` _is_ the revoke so v2 maps
has no separate key-expire verb (its `DELETE` _is_ the revoke), so v2 maps
`DELETE` to a soft revoke: the key stays retrievable with `invalid: true`
until the collector reaps it (`preauth_keys.revoked_retention`), the
equivalent of v1 `preauthkeys expire`. `headscale preauthkeys` still stays on
+3 -2
View File
@@ -10,6 +10,7 @@ import (
"strings"
"github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/scope"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
)
@@ -60,7 +61,7 @@ func registerACL(api huma.API, b Backend) {
Tags: aclTags,
Security: security,
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusInternalServerError},
}, ScopePolicyFileRead), func(ctx context.Context, in *getACLInput) (*huma.StreamResponse, error) {
}, scope.PolicyFileRead), func(ctx context.Context, in *getACLInput) (*huma.StreamResponse, error) {
err := requireDefaultTailnet(in.Tailnet)
if err != nil {
return nil, err
@@ -89,7 +90,7 @@ func registerACL(api huma.API, b Backend) {
http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden,
http.StatusNotFound, http.StatusPreconditionFailed, http.StatusInternalServerError,
},
}, ScopePolicyFile), func(ctx context.Context, in *setACLInput) (*huma.StreamResponse, error) {
}, scope.PolicyFile), func(ctx context.Context, in *setACLInput) (*huma.StreamResponse, error) {
err := requireDefaultTailnet(in.Tailnet)
if err != nil {
return nil, err
+104 -45
View File
@@ -1,8 +1,8 @@
// Package apiv2 is Headscale's v2 HTTP API, served at /api/v2.
//
// Where the v1 API (hscontrol/api/v1) is the headscale-native admin surface, v2
// additionally ports selected endpoints from Tailscale's API reusing
// Tailscale's wire shapes (paths, request/response JSON, error body) so the
// additionally ports selected endpoints from Tailscale's API, reusing
// Tailscale's wire shapes (paths, request/response JSON, error body), so the
// existing Tailscale ecosystem (the Terraform/OpenTofu provider, tscli, and
// tailscale.com/client/tailscale/v2) can drive Headscale unchanged. Ported
// operations carry the "Tailscale compat" tag; a headscale-native v2 operation
@@ -24,6 +24,7 @@ import (
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humachi"
"github.com/go-chi/chi/v5"
"github.com/juanfont/headscale/hscontrol/scope"
"github.com/juanfont/headscale/hscontrol/state"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/change"
@@ -40,7 +41,7 @@ type Backend struct {
}
// security is the requirement applied to authenticated operations: an API key
// presented as HTTP Basic (the key as username what the Tailscale SDK sends)
// presented as HTTP Basic (the key as username, what the Tailscale SDK sends)
// or as a Bearer token.
var security = []map[string][]string{{"basicAuth": {}}, {"bearerAuth": {}}}
@@ -79,7 +80,7 @@ func Config() huma.Config {
// Accept application/hujson request bodies (the Tailscale SDK sends the
// policy file that way). The bytes are captured raw by the ACL handler, so
// reusing the JSON format is only to satisfy huma's content-type check.
// Clone first config.Formats aliases huma's shared DefaultFormats map.
// Clone first: config.Formats aliases huma's shared DefaultFormats map.
formats := maps.Clone(config.Formats)
formats["application/hujson"] = formats["application/json"]
config.Formats = formats
@@ -98,6 +99,10 @@ func NewAPI(router chi.Router, backend Backend) huma.API {
Register(api, backend)
// The OAuth token endpoint is a plain route, not a Huma operation (see
// oauth.go); register it on the same router.
registerOAuthToken(router, backend)
return api
}
@@ -117,11 +122,21 @@ func Spec() ([]byte, error) {
return api.OpenAPI().YAML()
}
// Spec30 emits the document downgraded to OpenAPI 3.0.3, needed because
// oapi-codegen cannot yet read 3.1; the typed client is generated from this.
func Spec30() ([]byte, error) {
api := NewAPI(chi.NewMux(), Backend{})
return api.OpenAPI().DowngradeYAML()
}
type contextKey int
const (
localTrustKey contextKey = iota
ownerUserKey
principalScopesKey
principalTagsKey
)
// WithLocalTrust marks a request as arriving over a locally-trusted transport
@@ -136,8 +151,8 @@ func WithLocalTrust(next http.Handler) http.Handler {
}
// authMiddleware authenticates the API key (HTTP Basic with the key as the
// username what the Tailscale SDK sends or Bearer), records the key's
// owning user for handlers, and enforces the operation's required scope.
// username, what the Tailscale SDK sends, or Bearer), records the key's owning
// user for handlers, and enforces the operation's required scope.
func authMiddleware(api huma.API, b Backend) func(huma.Context, func(huma.Context)) {
return func(ctx huma.Context, next func(huma.Context)) {
if ctx.Context().Value(localTrustKey) != nil {
@@ -159,6 +174,35 @@ func authMiddleware(api huma.API, b Backend) func(huma.Context, func(huma.Contex
return
}
// An OAuth access token is scope-limited; an admin API key is all-access.
// They are told apart by prefix so a scoped token can never be mistaken
// for an all-access key.
if strings.HasPrefix(token, types.AccessTokenPrefix) {
at, err := b.State.AuthenticateAccessToken(token)
if err != nil {
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "unauthorized")
return
}
if want, ok := requiredScope(ctx.Operation()); ok && !scope.Grants(scope.Parse(at.Scopes), want) {
_ = huma.WriteErr(api, ctx, http.StatusForbidden,
"token is missing the required scope "+string(want))
return
}
// The keys handler multiplexes on keyType, so its required scope and
// permitted tags depend on the body; carry the token's scopes and tags
// for it to finish the check the static middleware cannot.
ctx = huma.WithValue(ctx, principalScopesKey, at.Scopes)
ctx = huma.WithValue(ctx, principalTagsKey, at.Tags)
next(ctx)
return
}
key, err := b.State.AuthenticateAPIKey(token)
if err != nil {
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "unauthorized")
@@ -166,13 +210,9 @@ func authMiddleware(api huma.API, b Backend) func(huma.Context, func(huma.Contex
return
}
// TODO(scopes): every valid key is all-access today. Operations still
// declare their required scope (requireScope) so that once OAuth tokens
// arrive, the granted set can be derived from the token and checked
// against the operation's scope here. Until then, accept everything.
// Record the key's owning user (may be unset) so handlers can create
// user-owned keys on its behalf.
// An admin API key is all-access: its operations are not scope-checked.
// Record its owning user (may be unset) so handlers can create user-owned
// keys on its behalf.
if key.UserID != nil {
ctx = huma.WithValue(ctx, ownerUserKey, types.UserID(*key.UserID))
}
@@ -210,6 +250,24 @@ func ownerUser(ctx context.Context) (types.UserID, bool) {
return uid, ok
}
// principalScopes returns the scopes granted to the request's OAuth access
// token, and whether the request authenticated with one. ok is false for an
// admin API key, which is all-access and not scope-checked.
func principalScopes(ctx context.Context) ([]string, bool) {
scopes, ok := ctx.Value(principalScopesKey).([]string)
return scopes, ok
}
// principalTags returns the tags granted to the request's OAuth access token,
// and whether the request authenticated with one. An admin API key is not an
// OAuth token, so ok is false and its key creation is unrestricted by tags.
func principalTags(ctx context.Context) ([]string, bool) {
tags, ok := ctx.Value(principalTagsKey).([]string)
return tags, ok
}
// requireDefaultTailnet rejects any tailnet other than "-". Headscale is
// single-tailnet; the Tailscale SDK sends "-" (its default tailnet). A non-"-"
// value is "no such tailnet", a 404, which lets the SDK's IsNotFound behave.
@@ -221,46 +279,47 @@ func requireDefaultTailnet(tailnet string) error {
return nil
}
// Scope is an OAuth capability an operation requires and a token grants. The
// names mirror Tailscale's API scopes (see the OAuth scope descriptions in the
// Tailscale OpenAPI spec); a ...Read scope is the read-only subset of its
// write scope. Nothing is enforced yet — every key is all-access — but every
// operation declares the scope it would require, so OAuth tokens can later be
// checked against it without reworking the operations.
type Scope string
// The scope vocabulary and the grant predicate live in the hscontrol/scope
// package; this file only wires a required scope onto each huma operation and
// reads it back in the middleware.
const (
ScopeAuthKeys Scope = "auth_keys"
ScopeAuthKeysRead Scope = "auth_keys:read"
ScopeDevicesCore Scope = "devices:core"
ScopeDevicesCoreRead Scope = "devices:core:read"
ScopeDevicesRoutes Scope = "devices:routes"
ScopeDevicesRoutesRead Scope = "devices:routes:read"
ScopePolicyFile Scope = "policy_file"
ScopePolicyFileRead Scope = "policy_file:read"
ScopeFeatureSettings Scope = "feature_settings"
ScopeFeatureSettingsRead Scope = "feature_settings:read"
ScopeUsers Scope = "users"
ScopeUsersRead Scope = "users:read"
)
// scopeMetaKey keys the per-operation required Scope in huma.Operation.Metadata.
// scopeMetaKey keys the per-operation required scope in huma.Operation.Metadata.
const scopeMetaKey = "headscale.scope"
// requireScope records op's required scope in its Metadata, where the auth
// middleware can read it back. It keeps the requirement next to the operation
// definition.
func requireScope(op huma.Operation, s Scope) huma.Operation {
// requireScope records op's required scope, both in its Metadata (where the auth
// middleware reads it back) and in the generated OpenAPI document: an
// x-required-scope extension for machine consumers and a Description line so the
// rendered docs state what each operation needs.
func requireScope(op huma.Operation, s scope.Scope) huma.Operation {
if op.Metadata == nil {
op.Metadata = map[string]any{}
}
op.Metadata[scopeMetaKey] = s
if op.Extensions == nil {
op.Extensions = map[string]any{}
}
op.Extensions["x-required-scope"] = string(s)
note := "Requires the `" + string(s) + "` OAuth scope (an admin API key is all-access)."
if op.Description == "" {
op.Description = note
} else {
op.Description += "\n\n" + note
}
return op
}
// requiredScope returns the scope an operation declared via requireScope, if any.
func requiredScope(op *huma.Operation) (scope.Scope, bool) {
if op == nil || op.Metadata == nil {
return "", false
}
s, ok := op.Metadata[scopeMetaKey].(scope.Scope)
return s, ok
}
+23 -9
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/scope"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
"tailscale.com/net/tsaddr"
@@ -125,7 +126,7 @@ func registerDevices(api huma.API, b Backend) {
Tags: deviceTags,
Security: security,
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
}, ScopeDevicesCoreRead), func(ctx context.Context, in *deviceByIDInput) (*deviceOutput, error) {
}, scope.DevicesCoreRead), func(ctx context.Context, in *deviceByIDInput) (*deviceOutput, error) {
node, err := lookupNode(b, in.DeviceID)
if err != nil {
return nil, err
@@ -142,7 +143,7 @@ func registerDevices(api huma.API, b Backend) {
Tags: deviceTags,
Security: security,
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
}, ScopeDevicesCoreRead), func(ctx context.Context, in *listDevicesInput) (*listDevicesOutput, error) {
}, scope.DevicesCoreRead), func(ctx context.Context, in *listDevicesInput) (*listDevicesOutput, error) {
err := requireDefaultTailnet(in.Tailnet)
if err != nil {
return nil, err
@@ -170,7 +171,7 @@ func registerDevices(api huma.API, b Backend) {
Security: security,
DefaultStatus: http.StatusOK,
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
}, ScopeDevicesCore), func(ctx context.Context, in *deviceByIDInput) (*emptyOutput, error) {
}, scope.DevicesCore), func(ctx context.Context, in *deviceByIDInput) (*emptyOutput, error) {
node, err := lookupNode(b, in.DeviceID)
if err != nil {
return nil, err
@@ -195,7 +196,7 @@ func registerDevices(api huma.API, b Backend) {
Security: security,
DefaultStatus: http.StatusOK,
Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
}, ScopeDevicesCore), func(ctx context.Context, in *setAuthorizedInput) (*emptyOutput, error) {
}, scope.DevicesCore), func(ctx context.Context, in *setAuthorizedInput) (*emptyOutput, error) {
_, err := lookupNode(b, in.DeviceID)
if err != nil {
return nil, err
@@ -222,7 +223,7 @@ func registerDevices(api huma.API, b Backend) {
Security: security,
DefaultStatus: http.StatusOK,
Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
}, ScopeDevicesCore), func(ctx context.Context, in *setNameInput) (*emptyOutput, error) {
}, scope.DevicesCore), func(ctx context.Context, in *setNameInput) (*emptyOutput, error) {
node, err := lookupNode(b, in.DeviceID)
if err != nil {
return nil, err
@@ -247,7 +248,7 @@ func registerDevices(api huma.API, b Backend) {
Security: security,
DefaultStatus: http.StatusOK,
Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
}, ScopeDevicesCore), func(ctx context.Context, in *setTagsInput) (*emptyOutput, error) {
}, scope.DevicesCore), func(ctx context.Context, in *setTagsInput) (*emptyOutput, error) {
node, err := lookupNode(b, in.DeviceID)
if err != nil {
return nil, err
@@ -261,6 +262,19 @@ func registerDevices(api huma.API, b Backend) {
return &emptyOutput{}, nil
}
// An OAuth token may only assign tags within its grant (held directly or
// owned by a held tag per policy); an admin API key is unrestricted. The
// devices:core scope alone must not let a token stamp an arbitrary policy
// tag (e.g. tag:prod) onto any node. SetNodeTags still enforces that each
// tag exists in policy.
if tokenTags, isOAuth := principalTags(ctx); isOAuth {
for _, tag := range in.Body.Tags {
if !b.State.TagOwnedByTags(tag, tokenTags) {
return nil, huma.Error403Forbidden("token may not assign tag " + tag)
}
}
}
_, nodeChange, err := b.State.SetNodeTags(node.ID(), in.Body.Tags)
if err != nil {
return nil, mapError("setting device tags", err)
@@ -280,7 +294,7 @@ func registerDevices(api huma.API, b Backend) {
Security: security,
DefaultStatus: http.StatusOK,
Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
}, ScopeDevicesCore), func(ctx context.Context, in *setKeyInput) (*emptyOutput, error) {
}, scope.DevicesCore), func(ctx context.Context, in *setKeyInput) (*emptyOutput, error) {
node, err := lookupNode(b, in.DeviceID)
if err != nil {
return nil, err
@@ -313,7 +327,7 @@ func registerDevices(api huma.API, b Backend) {
Security: security,
DefaultStatus: http.StatusOK,
Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
}, ScopeDevicesRoutes), func(ctx context.Context, in *setSubnetRoutesInput) (*deviceRoutesOutput, error) {
}, scope.DevicesRoutes), func(ctx context.Context, in *setSubnetRoutesInput) (*deviceRoutesOutput, error) {
node, err := lookupNode(b, in.DeviceID)
if err != nil {
return nil, err
@@ -342,7 +356,7 @@ func registerDevices(api huma.API, b Backend) {
Tags: deviceTags,
Security: security,
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
}, ScopeDevicesRoutesRead), func(ctx context.Context, in *deviceByIDInput) (*deviceRoutesOutput, error) {
}, scope.DevicesRoutesRead), func(ctx context.Context, in *deviceByIDInput) (*deviceRoutesOutput, error) {
node, err := lookupNode(b, in.DeviceID)
if err != nil {
return nil, err
+5 -5
View File
@@ -9,12 +9,12 @@ import (
"gorm.io/gorm"
)
// apiError is the Tailscale API error body. The official Tailscale Go client
// and therefore the Terraform provider and tscli built on it decode 4xx/5xx
// apiError is the Tailscale API error body. The official Tailscale Go client
// (and therefore the Terraform provider and tscli built on it) decodes 4xx/5xx
// responses into this shape. Huma's default RFC 9457 problem+json would reach
// them with an empty message, so every v2 error is rewritten into this shape by
// tailscaleErrorTransformer. The HTTP status is read from the response code,
// not from the body's status field.
// them with an empty message, so tailscaleErrorTransformer rewrites every v2
// error into this shape. The HTTP status is read from the response code, not
// from the body's status field.
type apiError struct {
Message string `json:"message"`
Data []apiErrorData `json:"data,omitempty"`
+298 -72
View File
@@ -3,10 +3,13 @@ package apiv2
import (
"context"
"net/http"
"strconv"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/scope"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
)
func init() {
@@ -17,9 +20,13 @@ func init() {
// 90 days, matching Tailscale and the Terraform provider default.
const defaultExpiry = 90 * 24 * time.Hour
// keyTypeAuth is the only key kind Headscale issues; Tailscale's OAuth-client
// and federated-identity kinds are out of scope.
const keyTypeAuth = "auth"
const (
// keyTypeAuth is a machine auth key (Headscale pre-auth key); the default.
keyTypeAuth = "auth"
// keyTypeClient is an OAuth client (client-credentials). Multiplexed onto the
// keys resource exactly as Tailscale does.
keyTypeClient = "client"
)
// KeyCapabilities maps a resource to the actions a key permits. Headscale
// populates only devices.create (auth keys); the named types (vs Tailscale's
@@ -42,16 +49,27 @@ type KeyDeviceCreateCapabilities struct {
Tags []string `json:"tags"`
}
// CreateKeyRequest is the POST body. expirySeconds is plain seconds (unlike the
// response, see Key).
// CreateKeyRequest is the POST body. It is multiplexed by keyType: "auth"
// (default) creates a machine auth key from Capabilities; "client" creates an
// OAuth client from the top-level Scopes and Tags. expirySeconds is plain
// seconds (unlike the response, see Key).
type CreateKeyRequest struct {
Capabilities KeyCapabilities `json:"capabilities"`
ExpirySeconds int64 `doc:"Lifetime in seconds; defaults to 90 days." json:"expirySeconds,omitempty"`
Description string `json:"description,omitempty" maxLength:"50"`
KeyType string `doc:"Key kind: \"auth\" (default) or \"client\" (OAuth client)." json:"keyType,omitempty"`
// Capabilities is optional: an auth key carries device-create capabilities,
// but an OAuth client (keyType:"client") has none and the Tailscale clients
// omit the field entirely, so it must not be required.
Capabilities *KeyCapabilities `json:"capabilities,omitempty"`
ExpirySeconds int64 `doc:"Lifetime in seconds; defaults to 90 days. Auth keys only." json:"expirySeconds,omitempty"`
Description string `json:"description,omitempty" maxLength:"50"`
// Scopes and Tags are top-level and apply only to keyType "client" (an OAuth
// client). Auth-key tags live under Capabilities.Devices.Create.Tags.
Scopes []string `doc:"OAuth scopes granted to the client. keyType=client only." json:"scopes,omitempty"`
Tags []string `doc:"Tags the client may assign. keyType=client only." json:"tags,omitempty"`
}
// Key is the Tailscale auth-key response. expirySeconds is emitted in seconds
// to match the Tailscale spec; the secret key is present only at creation.
// Key is the Tailscale key response, shared by auth keys and OAuth clients.
// expirySeconds is emitted in seconds to match the Tailscale spec; the secret
// key is present only at creation.
type Key struct {
ID string `json:"id"`
KeyType string `json:"keyType"`
@@ -63,6 +81,7 @@ type Key struct {
Revoked *time.Time `json:"revoked,omitempty"`
Invalid bool `json:"invalid"`
Capabilities KeyCapabilities `json:"capabilities"`
Scopes []string `json:"scopes,omitempty"`
Tags []string `json:"tags,omitempty"`
UserID string `json:"userId,omitempty"`
}
@@ -98,14 +117,19 @@ type (
}
)
// The keys resource is multiplexed by keyType (auth key vs OAuth client), so the
// scope an operation requires depends on the request rather than being fixed.
// requireScope (which the middleware enforces statically) is therefore omitted
// here; each handler authorizes via requireKeyScope once the kind is known.
func registerKeys(api huma.API, b Backend) {
keysTags := []string{"Keys", "Tailscale compat"}
huma.Register(api, requireScope(huma.Operation{
huma.Register(api, huma.Operation{
OperationID: "createKey",
Method: http.MethodPost,
Path: "/api/v2/tailnet/{tailnet}/keys",
Summary: "Create an auth key",
Summary: "Create an auth key or OAuth client",
Description: "Requires the `auth_keys` scope for an auth key, or `oauth_keys` for an OAuth client (an admin API key is all-access).",
Tags: keysTags,
Security: security,
Errors: []int{
@@ -114,63 +138,25 @@ func registerKeys(api huma.API, b Backend) {
http.StatusForbidden,
http.StatusNotFound,
},
}, ScopeAuthKeys), func(ctx context.Context, in *createKeyInput) (*keyOutput, error) {
}, func(ctx context.Context, in *createKeyInput) (*keyOutput, error) {
err := requireDefaultTailnet(in.Tailnet)
if err != nil {
return nil, err
}
create := in.Body.Capabilities.Devices.Create
// Ownership: tags -> tagged key; no tags -> owned by the API key's user;
// no tags and an ownerless (legacy/admin) key -> 400.
var userID *types.UserID
if len(create.Tags) == 0 {
uid, ok := ownerUser(ctx)
if !ok {
return nil, huma.Error400BadRequest(
"an auth key without tags must be created with a user-owned API key",
)
}
userID = &uid
if in.Body.KeyType == keyTypeClient {
return createOAuthClient(ctx, b, in.Body)
}
expiration := time.Now().Add(expiryDuration(in.Body.ExpirySeconds))
pak, err := b.State.CreatePreAuthKey(
userID,
create.Reusable,
create.Ephemeral,
&expiration,
create.Tags,
)
if err != nil {
return nil, mapError("creating auth key", err)
}
if in.Body.Description != "" {
err := b.State.SetPreAuthKeyDescription(
pak.ID,
in.Body.Description,
)
if err != nil {
return nil, huma.Error500InternalServerError(
"setting auth key description",
err,
)
}
}
return &keyOutput{Body: keyFromNew(pak, in.Body)}, nil
return createAuthKey(ctx, b, in.Body)
})
huma.Register(api, requireScope(huma.Operation{
huma.Register(api, huma.Operation{
OperationID: "listKeys",
Method: http.MethodGet,
Path: "/api/v2/tailnet/{tailnet}/keys",
Summary: "List auth keys",
Summary: "List auth keys and OAuth clients",
Description: "A token sees the kinds it can read: `auth_keys:read` for auth keys, `oauth_keys:read` for OAuth clients (an admin API key sees all).",
Tags: keysTags,
Security: security,
Errors: []int{
@@ -178,32 +164,49 @@ func registerKeys(api huma.API, b Backend) {
http.StatusForbidden,
http.StatusNotFound,
},
}, ScopeAuthKeysRead), func(ctx context.Context, in *listKeysInput) (*listKeysOutput, error) {
}, func(ctx context.Context, in *listKeysInput) (*listKeysOutput, error) {
err := requireDefaultTailnet(in.Tailnet)
if err != nil {
return nil, err
}
keys, err := b.State.ListPreAuthKeys()
if err != nil {
return nil, huma.Error500InternalServerError("listing auth keys", err)
}
scopes, isOAuth := principalScopes(ctx)
out := &listKeysOutput{}
out.Body.Keys = make([]Key, 0, len(keys))
out.Body.Keys = []Key{}
for i := range keys {
out.Body.Keys = append(out.Body.Keys, keyFromStored(&keys[i]))
// A token sees the key kinds it has read scope for; an admin key sees all.
if !isOAuth || scope.Grants(scope.Parse(scopes), scope.AuthKeysRead) {
keys, err := b.State.ListPreAuthKeys()
if err != nil {
return nil, huma.Error500InternalServerError("listing auth keys", err)
}
for i := range keys {
out.Body.Keys = append(out.Body.Keys, keyFromStored(&keys[i]))
}
}
if !isOAuth || scope.Grants(scope.Parse(scopes), scope.OAuthKeysRead) {
clients, err := b.State.ListOAuthClients()
if err != nil {
return nil, huma.Error500InternalServerError("listing oauth clients", err)
}
for i := range clients {
out.Body.Keys = append(out.Body.Keys, oauthClientToKey(&clients[i], ""))
}
}
return out, nil
})
huma.Register(api, requireScope(huma.Operation{
huma.Register(api, huma.Operation{
OperationID: "getKey",
Method: http.MethodGet,
Path: "/api/v2/tailnet/{tailnet}/keys/{keyId}",
Summary: "Get an auth key",
Summary: "Get an auth key or OAuth client",
Description: "Requires `auth_keys:read` for an auth key, or `oauth_keys:read` for an OAuth client (an admin API key is all-access).",
Tags: keysTags,
Security: security,
Errors: []int{
@@ -211,12 +214,29 @@ func registerKeys(api huma.API, b Backend) {
http.StatusForbidden,
http.StatusNotFound,
},
}, ScopeAuthKeysRead), func(ctx context.Context, in *keyByIDInput) (*keyOutput, error) {
}, func(ctx context.Context, in *keyByIDInput) (*keyOutput, error) {
err := requireDefaultTailnet(in.Tailnet)
if err != nil {
return nil, err
}
// An OAuth client id is a hex string distinct from a numeric auth-key id,
// so a client lookup that hits is authoritative; otherwise fall through to
// the auth-key path. The lookup is gated on the caller actually holding
// oauth_keys:read so a token without it cannot tell a real client id (403)
// from an unknown key (404) — i.e. no client-existence oracle.
if requireKeyScope(ctx, scope.OAuthKeysRead) == nil {
client, err := b.State.GetOAuthClientByClientID(in.KeyID)
if err == nil {
return &keyOutput{Body: oauthClientToKey(client, "")}, nil
}
}
err = requireKeyScope(ctx, scope.AuthKeysRead)
if err != nil {
return nil, err
}
key, err := findKeyByID(b, in.KeyID)
if err != nil {
return nil, err
@@ -225,11 +245,12 @@ func registerKeys(api huma.API, b Backend) {
return &keyOutput{Body: keyFromStored(key)}, nil
})
huma.Register(api, requireScope(huma.Operation{
huma.Register(api, huma.Operation{
OperationID: "deleteKey",
Method: http.MethodDelete,
Path: "/api/v2/tailnet/{tailnet}/keys/{keyId}",
Summary: "Delete an auth key",
Summary: "Delete an auth key or OAuth client",
Description: "Requires the `auth_keys` scope for an auth key, or `oauth_keys` for an OAuth client (an admin API key is all-access).",
Tags: keysTags,
Security: security,
DefaultStatus: http.StatusOK,
@@ -238,12 +259,31 @@ func registerKeys(api huma.API, b Backend) {
http.StatusForbidden,
http.StatusNotFound,
},
}, ScopeAuthKeys), func(ctx context.Context, in *keyByIDInput) (*deleteKeyOutput, error) {
}, func(ctx context.Context, in *keyByIDInput) (*deleteKeyOutput, error) {
err := requireDefaultTailnet(in.Tailnet)
if err != nil {
return nil, err
}
// Gated on oauth_keys (write) for the same no-existence-oracle reason as
// getKey: a token without it must not learn that an id is an OAuth client.
if requireKeyScope(ctx, scope.OAuthKeys) == nil {
_, err = b.State.GetOAuthClientByClientID(in.KeyID)
if err == nil {
err = b.State.RevokeOAuthClient(in.KeyID)
if err != nil {
return nil, mapError("deleting oauth client", err)
}
return &deleteKeyOutput{}, nil
}
}
err = requireKeyScope(ctx, scope.AuthKeys)
if err != nil {
return nil, err
}
id, err := parseID(in.KeyID, "auth key")
if err != nil {
return nil, err
@@ -261,6 +301,163 @@ func registerKeys(api huma.API, b Backend) {
})
}
// requireKeyScope authorizes a keys operation for an OAuth access token. An admin
// API key carries no OAuth scopes and is all-access, so it always passes.
func requireKeyScope(ctx context.Context, need scope.Scope) error {
scopes, isOAuth := principalScopes(ctx)
if !isOAuth {
return nil
}
if !scope.Grants(scope.Parse(scopes), need) {
return huma.Error403Forbidden("token is missing the required scope " + string(need))
}
return nil
}
// createAuthKey creates a machine auth key (pre-auth key). Ownership: tags -> a
// tagged key; no tags -> owned by the API key's user. An OAuth access token must
// mint tagged keys, and each tag must be within the token's grant.
func createAuthKey(ctx context.Context, b Backend, body CreateKeyRequest) (*keyOutput, error) {
err := requireKeyScope(ctx, scope.AuthKeys)
if err != nil {
return nil, err
}
var create KeyDeviceCreateCapabilities
if body.Capabilities != nil {
create = body.Capabilities.Devices.Create
}
tokenTags, isOAuth := principalTags(ctx)
var userID *types.UserID
switch {
case len(create.Tags) > 0:
// A key minted by an OAuth token may only carry tags within the token's
// grant (held directly, or owned by a held tag) and defined in policy,
// matching SetNodeTags. An admin key keeps the historical behaviour of
// validating only tag syntax (db.validateACLTags).
if isOAuth {
for _, tag := range create.Tags {
if !b.State.TagExists(tag) {
return nil, huma.Error400BadRequest("tag " + tag + " is not defined in policy")
}
if !b.State.TagOwnedByTags(tag, tokenTags) {
return nil, huma.Error403Forbidden(
"token may not assign tag " + tag,
)
}
}
}
case isOAuth:
// OAuth-minted keys are tailnet/tag-owned; an untagged (user-owned) key
// cannot be created from a token.
return nil, huma.Error403Forbidden("an OAuth client must create tagged auth keys")
default:
uid, ok := ownerUser(ctx)
if !ok {
return nil, huma.Error400BadRequest(
"an auth key without tags must be created with a user-owned API key",
)
}
userID = &uid
}
expiration := time.Now().Add(expiryDuration(body.ExpirySeconds))
pak, err := b.State.CreatePreAuthKey(
userID,
create.Reusable,
create.Ephemeral,
&expiration,
create.Tags,
)
if err != nil {
return nil, mapError("creating auth key", err)
}
if body.Description != "" {
err := b.State.SetPreAuthKeyDescription(pak.ID, body.Description)
if err != nil {
return nil, huma.Error500InternalServerError("setting auth key description", err)
}
}
return &keyOutput{Body: keyFromNew(pak, body)}, nil
}
// createOAuthClient creates an OAuth client (keyType:"client"). The client secret
// is returned once, here.
func createOAuthClient(ctx context.Context, b Backend, body CreateKeyRequest) (*keyOutput, error) {
err := requireKeyScope(ctx, scope.OAuthKeys)
if err != nil {
return nil, err
}
if len(body.Scopes) == 0 {
return nil, huma.Error400BadRequest("an OAuth client must declare at least one scope")
}
// Tailscale: tags are mandatory when the scopes include devices:core or
// auth_keys, because such a client mints tagged, tailnet-owned credentials.
if scope.RequiresTags(scope.Parse(body.Scopes)) && len(body.Tags) == 0 {
return nil, huma.Error400BadRequest(
"tags are required when scopes include devices:core or auth_keys",
)
}
// A client created by an OAuth token may not be granted authority the token
// lacks: its scopes must each be within the token's grant, and its tags within
// the token's tags and defined in policy (matching SetNodeTags). Otherwise an
// oauth_keys token could mint an all-access client and escalate. An admin API
// key (not an OAuth token) is unrestricted and keeps the historical tag
// behaviour (syntax-only validation).
if tokenScopes, isOAuth := principalScopes(ctx); isOAuth {
for _, s := range body.Scopes {
if !scope.Grants(scope.Parse(tokenScopes), scope.Scope(s)) {
return nil, huma.Error403Forbidden(
"client may not be granted scope " + s + " beyond the creating token",
)
}
}
tokenTags, _ := principalTags(ctx)
for _, tag := range body.Tags {
if !b.State.TagExists(tag) {
return nil, huma.Error400BadRequest("tag " + tag + " is not defined in policy")
}
if !b.State.TagOwnedByTags(tag, tokenTags) {
return nil, huma.Error403Forbidden(
"client may not be granted tag " + tag + " beyond the creating token",
)
}
}
}
var creator *uint
if uid, ok := ownerUser(ctx); ok {
u := uint(uid)
creator = &u
}
secret, client, err := b.State.CreateOAuthClient(body.Scopes, body.Tags, body.Description, creator)
if err != nil {
return nil, mapError("creating oauth client", err)
}
return &keyOutput{Body: oauthClientToKey(client, secret)}, nil
}
// findKeyByID looks up a stored pre-auth key by its (stringified) id with a
// direct by-id query; an unknown id surfaces as gorm.ErrRecordNotFound, which
// mapError turns into a 404.
@@ -283,7 +480,10 @@ func findKeyByID(b Backend, rawID string) (*types.PreAuthKey, error) {
// match keyFromStored: Headscale always authorizes pre-auth-key nodes, so the
// create and read paths must agree or the Terraform provider sees a diff.
func keyFromNew(pak *types.PreAuthKeyNew, req CreateKeyRequest) Key {
create := req.Capabilities.Devices.Create
var create KeyDeviceCreateCapabilities
if req.Capabilities != nil {
create = req.Capabilities.Devices.Create
}
key := Key{
ID: pak.StringID(),
@@ -344,6 +544,32 @@ func keyFromStored(pak *types.PreAuthKey) Key {
return key
}
// oauthClientToKey builds the keys response for an OAuth client. secret is the
// plaintext client secret, set only on the create response and empty on get/list
// (the secret is never re-exposed).
func oauthClientToKey(client *types.OAuthClient, secret string) Key {
key := Key{
ID: client.ClientID,
KeyType: keyTypeClient,
Key: secret,
Description: client.Description,
Created: timeOrZero(client.CreatedAt),
Scopes: emptyIfNil(client.Scopes),
Tags: emptyIfNil(client.Tags),
}
if client.Revoked != nil {
key.Revoked = client.Revoked
key.Invalid = true
}
if client.UserID != nil {
key.UserID = strconv.FormatUint(uint64(*client.UserID), util.Base10)
}
return key
}
func capabilities(
reusable, ephemeral, preauthorized bool,
tags []string,
+178
View File
@@ -0,0 +1,178 @@
package apiv2
import (
"encoding/json"
"net/http"
"slices"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/juanfont/headscale/hscontrol/scope"
"github.com/juanfont/headscale/hscontrol/types"
)
// accessTokenTTL is the fixed lifetime of a minted access token, matching
// Tailscale's non-configurable one hour.
const accessTokenTTL = time.Hour
// registerOAuthToken mounts POST /api/v2/oauth/token on the router. It is a plain
// handler, not a Huma operation: it consumes application/x-www-form-urlencoded
// and emits RFC 6749 OAuth2 error bodies ({"error","error_description"}), neither
// of which fits Huma's JSON-in / Tailscale-error-out machinery.
// ponytail: one bespoke OAuth endpoint isn't worth bending Huma around.
func registerOAuthToken(router chi.Router, b Backend) {
router.Post("/api/v2/oauth/token", oauthTokenHandler(b))
}
// tokenResponse is the OAuth 2.0 client-credentials success body.
type tokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Scope string `json:"scope,omitempty"`
}
// oauthTokenHandler implements the client-credentials grant (RFC 6749 §4.4):
// authenticate the client, optionally narrow the granted scopes/tags, and mint a
// short-lived bearer token.
func oauthTokenHandler(b Backend) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "could not parse request body")
return
}
// grant_type defaults to client_credentials: Tailscale's documented curl
// omits it, and the x/oauth2 client always sends it.
if gt := r.PostForm.Get("grant_type"); gt != "" && gt != "client_credentials" {
writeOAuthError(w, http.StatusBadRequest, "unsupported_grant_type",
"only the client_credentials grant is supported")
return
}
// Credentials may arrive in the body or as HTTP Basic (the x/oauth2
// auto-detect probes Basic first). The secret embeds the client id, so a
// separate client_id is not required.
secret := r.PostForm.Get("client_secret")
if secret == "" {
if _, pass, ok := r.BasicAuth(); ok {
secret = pass
}
}
if secret == "" {
writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "missing client credentials")
return
}
client, err := b.State.AuthenticateOAuthClient(secret)
if err != nil {
writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "invalid client credentials")
return
}
// Optional space-delimited scope/tags narrow the token to a subset of the
// client's grant.
scopes, badScope, ok := narrowScopes(client.Scopes, strings.Fields(r.PostForm.Get("scope")))
if !ok {
writeOAuthError(w, http.StatusBadRequest, "invalid_scope",
"scope "+badScope+" is not granted to this client")
return
}
tags, badTag, ok := narrowTags(client, strings.Fields(r.PostForm.Get("tags")))
if !ok {
writeOAuthError(w, http.StatusBadRequest, "invalid_target",
"tag "+badTag+" is not granted to this client")
return
}
expiry := time.Now().Add(accessTokenTTL)
tokenStr, _, err := b.State.MintAccessToken(client.ClientID, scopes, tags, &expiry)
if err != nil {
writeOAuthError(w, http.StatusInternalServerError, "server_error", "could not mint access token")
return
}
writeJSON(w, http.StatusOK, tokenResponse{
AccessToken: tokenStr,
TokenType: "Bearer",
ExpiresIn: int(accessTokenTTL.Seconds()),
Scope: strings.Join(scopes, " "),
})
}
}
// narrowScopes returns the requested scopes if each is granted by the client (an
// empty request means "the client's full grant"), otherwise the offending scope
// and false. A client holding a broad scope (e.g. "all") may mint a token limited
// to a narrower one.
func narrowScopes(granted, requested []string) ([]string, string, bool) {
if len(requested) == 0 {
return granted, "", true
}
for _, req := range requested {
if !scope.Grants(scope.Parse(granted), scope.Scope(req)) {
return nil, req, false
}
}
return requested, "", true
}
// narrowTags returns the requested tags if each is within the client's grant (an
// empty request means "the client's full tag set"), otherwise the offending tag
// and false. A client with the "all" scope may assign any tag, matching Tailscale.
func narrowTags(client *types.OAuthClient, requested []string) ([]string, string, bool) {
if len(requested) == 0 {
return client.Tags, "", true
}
allScope := slices.Contains(client.Scopes, string(scope.All))
for _, req := range requested {
// Reject malformed tags at the trust boundary. A client's own tags are
// validated at creation, so this only matters for an "all"-scope client,
// whose tags would otherwise skip the membership check below and flow
// unvalidated into auth-key creation.
if !strings.HasPrefix(req, "tag:") {
return nil, req, false
}
if !allScope && !slices.Contains(client.Tags, req) {
return nil, req, false
}
}
return requested, "", true
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
// Token responses carry bearer credentials; RFC 6749 §5.1 forbids caching
// them. writeJSON serves only the token endpoint, so set it unconditionally.
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v) //nolint:errchkjson // best-effort response write of a known-safe value
}
// writeOAuthError emits an RFC 6749 §5.2 error body, which the x/oauth2 client
// parses into its RetrieveError.
func writeOAuthError(w http.ResponseWriter, status int, code, desc string) {
writeJSON(w, status, map[string]string{
"error": code,
"error_description": desc,
})
}
+31
View File
@@ -0,0 +1,31 @@
package apiv2
import (
"testing"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
)
// TestNarrowTagsRejectsMalformed asserts the token endpoint validates tag
// format at the trust boundary. An "all"-scope client may assign any tag, but a
// malformed tag (missing the "tag:" prefix) must still be rejected rather than
// flowing unvalidated into auth-key creation.
func TestNarrowTagsRejectsMalformed(t *testing.T) {
all := &types.OAuthClient{Scopes: []string{"all"}}
_, bad, ok := narrowTags(all, []string{"not-a-tag"})
assert.False(t, ok, "malformed tag must be rejected even with all scope")
assert.Equal(t, "not-a-tag", bad)
got, _, ok := narrowTags(all, []string{"tag:anything"})
assert.True(t, ok, "all scope may assign any well-formed tag")
assert.Equal(t, []string{"tag:anything"}, got)
// A scoped client may only assign its own well-formed tags.
scoped := &types.OAuthClient{Scopes: []string{"auth_keys"}, Tags: []string{"tag:ci"}}
_, bad, ok = narrowTags(scoped, []string{"tag:other"})
assert.False(t, ok)
assert.Equal(t, "tag:other", bad)
}
+3 -2
View File
@@ -7,6 +7,7 @@ import (
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/scope"
"github.com/juanfont/headscale/hscontrol/types"
)
@@ -59,7 +60,7 @@ func registerSettings(api huma.API, b Backend) {
Tags: settingsTags,
Security: security,
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
}, ScopeFeatureSettingsRead), func(ctx context.Context, in *getSettingsInput) (*settingsOutput, error) {
}, scope.FeatureSettingsRead), func(ctx context.Context, in *getSettingsInput) (*settingsOutput, error) {
err := requireDefaultTailnet(in.Tailnet)
if err != nil {
return nil, err
@@ -86,7 +87,7 @@ func registerSettings(api huma.API, b Backend) {
// The body is accepted but ignored; skip validation.
SkipValidateBody: true,
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusNotImplemented},
}, ScopeFeatureSettings), func(ctx context.Context, in *patchSettingsInput) (*settingsOutput, error) {
}, scope.FeatureSettings), func(ctx context.Context, in *patchSettingsInput) (*settingsOutput, error) {
err := requireDefaultTailnet(in.Tailnet)
if err != nil {
return nil, err
+3 -2
View File
@@ -7,6 +7,7 @@ import (
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/scope"
"github.com/juanfont/headscale/hscontrol/types"
)
@@ -73,7 +74,7 @@ func registerUsers(api huma.API, b Backend) {
Tags: usersTags,
Security: security,
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
}, ScopeUsersRead), func(ctx context.Context, in *userByIDInput) (*userOutput, error) {
}, scope.UsersRead), func(ctx context.Context, in *userByIDInput) (*userOutput, error) {
view, err := lookupUser(b, in.UserID)
if err != nil {
return nil, err
@@ -90,7 +91,7 @@ func registerUsers(api huma.API, b Backend) {
Tags: usersTags,
Security: security,
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
}, ScopeUsersRead), func(ctx context.Context, in *listUsersInput) (*listUsersOutput, error) {
}, scope.UsersRead), func(ctx context.Context, in *listUsersInput) (*listUsersOutput, error) {
err := requireDefaultTailnet(in.Tailnet)
if err != nil {
return nil, err
+7 -7
View File
@@ -17,8 +17,8 @@ import (
)
// taggedCaps builds capabilities for a tagged auth key.
func taggedCaps(tags ...string) apiv2.KeyCapabilities {
return apiv2.KeyCapabilities{
func taggedCaps(tags ...string) *apiv2.KeyCapabilities {
return &apiv2.KeyCapabilities{
Devices: apiv2.KeyDeviceCapabilities{Create: apiv2.KeyDeviceCreateCapabilities{
Reusable: true,
Preauthorized: true,
@@ -138,7 +138,7 @@ func TestAPIv2Key_Create_Tagged(t *testing.T) {
KeyType: "auth",
Description: "dev access",
ExpirySeconds: 86400,
Capabilities: taggedCaps("tag:test"),
Capabilities: *taggedCaps("tag:test"),
Tags: []string{"tag:test"},
}
if diff := cmp.Diff(want, created, cmpopts.IgnoreFields(apiv2.Key{}, "ID", "Key", "Created", "Expires")); diff != "" {
@@ -172,7 +172,7 @@ func TestAPIv2Key_Create_Permutations(t *testing.T) {
}{
{
name: "single-use",
req: apiv2.CreateKeyRequest{Capabilities: apiv2.KeyCapabilities{
req: apiv2.CreateKeyRequest{Capabilities: &apiv2.KeyCapabilities{
Devices: apiv2.KeyDeviceCapabilities{Create: apiv2.KeyDeviceCreateCapabilities{Tags: []string{"tag:test"}}},
}},
wantSeconds: 7776000,
@@ -185,7 +185,7 @@ func TestAPIv2Key_Create_Permutations(t *testing.T) {
},
{
name: "ephemeral",
req: apiv2.CreateKeyRequest{Capabilities: apiv2.KeyCapabilities{
req: apiv2.CreateKeyRequest{Capabilities: &apiv2.KeyCapabilities{
Devices: apiv2.KeyDeviceCapabilities{Create: apiv2.KeyDeviceCreateCapabilities{
Ephemeral: true,
Tags: []string{"tag:test"},
@@ -246,7 +246,7 @@ func TestAPIv2Key_Get(t *testing.T) {
KeyType: "auth",
Description: "dev access",
ExpirySeconds: 86400,
Capabilities: taggedCaps("tag:test"),
Capabilities: *taggedCaps("tag:test"),
Tags: []string{"tag:test"},
}
if diff := cmp.Diff(want, got, cmpopts.IgnoreFields(apiv2.Key{}, "Created", "Expires")); diff != "" {
@@ -313,7 +313,7 @@ func TestAPIv2Key_Create_NoTags_NoOwner_400(t *testing.T) {
before := keyCount(t, app)
resp := api.Post("/api/v2/tailnet/-/keys", apiv2.CreateKeyRequest{Capabilities: apiv2.KeyCapabilities{}})
resp := api.Post("/api/v2/tailnet/-/keys", apiv2.CreateKeyRequest{Capabilities: &apiv2.KeyCapabilities{}})
assert.Equal(t, http.StatusBadRequest, resp.Code)
assert.Contains(t, resp.Body.String(), `"message"`)
+5 -1
View File
@@ -40,6 +40,9 @@ const (
FeatureSettings Scope = "feature_settings"
FeatureSettingsRead Scope = "feature_settings:read"
Users Scope = "users"
UsersRead Scope = "users:read"
)
const readSuffix = ":read"
@@ -55,6 +58,7 @@ func Known() []Scope {
DevicesRoutes, DevicesRoutesRead,
PolicyFile, PolicyFileRead,
FeatureSettings, FeatureSettingsRead,
Users, UsersRead,
}
}
@@ -69,7 +73,7 @@ func (s Scope) IsWrite() bool {
}
// 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.
// Unknown strings are kept as-is; they simply never satisfy any required scope.
func Parse(ss []string) []Scope {
out := make([]Scope, len(ss))
for i, s := range ss {