feat(sense): implement Control API v1 [T-011]
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
// Package auth defines the verified principal boundary for Sense HTTP APIs.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
PermissionDevicesRead = "sense.devices.read"
|
||||
PermissionDevicesWrite = "sense.devices.write"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnauthenticated = errors.New("unauthenticated")
|
||||
logicalIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$`)
|
||||
)
|
||||
|
||||
type Principal struct {
|
||||
SubjectID string
|
||||
ActorType string
|
||||
TenantID string
|
||||
SiteIDs []string
|
||||
Permissions map[string]struct{}
|
||||
}
|
||||
|
||||
func (p Principal) AllowsSite(siteID string) bool {
|
||||
for _, allowed := range p.SiteIDs {
|
||||
if allowed == "*" || allowed == siteID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p Principal) Has(permission string) bool {
|
||||
_, ok := p.Permissions[permission]
|
||||
return ok
|
||||
}
|
||||
|
||||
type Authenticator interface {
|
||||
Authenticate(context.Context, string) (Principal, error)
|
||||
}
|
||||
|
||||
type staticFile struct {
|
||||
Version int `json:"version"`
|
||||
Principals []staticPrincipal `json:"principals"`
|
||||
}
|
||||
|
||||
type staticPrincipal struct {
|
||||
TokenSHA256 string `json:"token_sha256"`
|
||||
SubjectID string `json:"subject_id"`
|
||||
ActorType string `json:"actor_type"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
SiteIDs []string `json:"site_ids"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
|
||||
type staticEntry struct {
|
||||
digest [sha256.Size]byte
|
||||
principal Principal
|
||||
}
|
||||
|
||||
// StaticSHA256 authenticates opaque tokens against externally provisioned
|
||||
// SHA-256 digests. The source file is read only during process startup.
|
||||
type StaticSHA256 struct {
|
||||
entries []staticEntry
|
||||
}
|
||||
|
||||
func LoadStaticSHA256(path string) (*StaticSHA256, error) {
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, errors.New("read static authentication registry")
|
||||
}
|
||||
decoder := json.NewDecoder(strings.NewReader(string(contents)))
|
||||
decoder.DisallowUnknownFields()
|
||||
var document staticFile
|
||||
if err := decoder.Decode(&document); err != nil {
|
||||
return nil, errors.New("decode static authentication registry")
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
return nil, errors.New("static authentication registry has trailing JSON")
|
||||
}
|
||||
if document.Version != 1 || len(document.Principals) == 0 {
|
||||
return nil, errors.New("static authentication registry must contain version 1 principals")
|
||||
}
|
||||
entries := make([]staticEntry, 0, len(document.Principals))
|
||||
seenDigests := make(map[string]struct{}, len(document.Principals))
|
||||
for index, value := range document.Principals {
|
||||
entry, validationErr := parseStaticPrincipal(value)
|
||||
if validationErr != nil {
|
||||
return nil, fmt.Errorf("invalid static authentication principal %d: %w", index, validationErr)
|
||||
}
|
||||
if _, exists := seenDigests[value.TokenSHA256]; exists {
|
||||
return nil, errors.New("duplicate static authentication token digest")
|
||||
}
|
||||
seenDigests[value.TokenSHA256] = struct{}{}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return &StaticSHA256{entries: entries}, nil
|
||||
}
|
||||
|
||||
func parseStaticPrincipal(value staticPrincipal) (staticEntry, error) {
|
||||
var entry staticEntry
|
||||
if len(value.TokenSHA256) != sha256.Size*2 || value.TokenSHA256 != strings.ToLower(value.TokenSHA256) {
|
||||
return entry, errors.New("token_sha256 must be 64 lowercase hexadecimal characters")
|
||||
}
|
||||
digest, err := hex.DecodeString(value.TokenSHA256)
|
||||
if err != nil {
|
||||
return entry, errors.New("token_sha256 must be hexadecimal")
|
||||
}
|
||||
copy(entry.digest[:], digest)
|
||||
if strings.TrimSpace(value.SubjectID) == "" || len(value.SubjectID) > 200 {
|
||||
return entry, errors.New("subject_id must contain 1 to 200 characters")
|
||||
}
|
||||
if value.ActorType != "user" && value.ActorType != "service" {
|
||||
return entry, errors.New("actor_type must be user or service")
|
||||
}
|
||||
if !logicalIDPattern.MatchString(value.TenantID) {
|
||||
return entry, errors.New("tenant_id is invalid")
|
||||
}
|
||||
if len(value.SiteIDs) == 0 {
|
||||
return entry, errors.New("site_ids must not be empty")
|
||||
}
|
||||
sites := make([]string, 0, len(value.SiteIDs))
|
||||
seenSites := make(map[string]struct{}, len(value.SiteIDs))
|
||||
for _, siteID := range value.SiteIDs {
|
||||
if siteID != "*" && !logicalIDPattern.MatchString(siteID) {
|
||||
return entry, errors.New("site_ids contains an invalid site")
|
||||
}
|
||||
if _, exists := seenSites[siteID]; exists {
|
||||
return entry, errors.New("site_ids contains a duplicate")
|
||||
}
|
||||
seenSites[siteID] = struct{}{}
|
||||
sites = append(sites, siteID)
|
||||
}
|
||||
permissions := make(map[string]struct{}, len(value.Permissions))
|
||||
for _, permission := range value.Permissions {
|
||||
if permission != PermissionDevicesRead && permission != PermissionDevicesWrite {
|
||||
return entry, errors.New("permissions contains an unsupported value")
|
||||
}
|
||||
if _, exists := permissions[permission]; exists {
|
||||
return entry, errors.New("permissions contains a duplicate")
|
||||
}
|
||||
permissions[permission] = struct{}{}
|
||||
}
|
||||
if len(permissions) == 0 {
|
||||
return entry, errors.New("permissions must not be empty")
|
||||
}
|
||||
entry.principal = Principal{
|
||||
SubjectID: value.SubjectID, ActorType: value.ActorType, TenantID: value.TenantID,
|
||||
SiteIDs: sites, Permissions: permissions,
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func (a *StaticSHA256) Authenticate(_ context.Context, token string) (Principal, error) {
|
||||
if len(token) < 22 || len(token) > 4096 {
|
||||
return Principal{}, ErrUnauthenticated
|
||||
}
|
||||
digest := sha256.Sum256([]byte(token))
|
||||
match := -1
|
||||
for index := range a.entries {
|
||||
if subtle.ConstantTimeCompare(digest[:], a.entries[index].digest[:]) == 1 {
|
||||
match = index
|
||||
}
|
||||
}
|
||||
if match < 0 {
|
||||
return Principal{}, ErrUnauthenticated
|
||||
}
|
||||
return a.entries[match].principal, nil
|
||||
}
|
||||
Reference in New Issue
Block a user