Files
llmgw/internal/scim/scim.go
T

610 lines
18 KiB
Go

// Package scim lets an identity provider keep the gateway's accounts in
// step with its own (SCIM 2.0, RFC 7643/7644): people made, renamed,
// suspended and reinstated as they are there, and the groups that give
// staff roles and places in the tree followed as people join and leave them.
//
// What it takes of SCIM is what Entra ID, Okta and Keycloak send: Users by
// userName (the email address), active, name; Groups by displayName, their
// members changed with PATCH. Deleting a user suspends the account: its keys,
// usage and history stay, and reinstating it gives it all back.
package scim
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base32"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"regexp"
"slices"
"sort"
"strconv"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// Store is what provisioning needs.
type Store interface {
Users(ctx context.Context, state store.UserState) ([]store.User, error)
UserByID(ctx context.Context, id string) (store.User, error)
UserByEmail(ctx context.Context, email string) (store.User, error)
CreateUser(ctx context.Context, u store.User) error
SetUserState(ctx context.Context, id string, state store.UserState) error
DeleteUserSessions(ctx context.Context, userID string) error
SetStaffRole(ctx context.Context, id, role string) error
AddMembership(ctx context.Context, user string, scope store.Scope, id, role, by string, at time.Time) error
RemoveMembership(ctx context.Context, user string, scope store.Scope, id string, at time.Time) (int, error)
AllOrgs(ctx context.Context) ([]store.Org, error)
AllTeams(ctx context.Context) ([]store.TeamPath, error)
AllProjects(ctx context.Context) ([]store.ProjectPath, error)
}
// Mapping says what a group stands for: staff roles by group, and places in
// the tree by group — the same as single sign-on's.
type Mapping struct {
Roles map[string][]string
Memberships map[string]string
}
// Server answers /scim/v2.
type Server struct {
st Store
token string
m Mapping
log *slog.Logger
}
// New builds the server. token is what the provider sends as its bearer.
func New(st Store, token string, m Mapping, log *slog.Logger) *Server {
if log == nil {
log = slog.Default()
}
return &Server{st: st, token: token, m: m, log: log}
}
const (
userSchema = "urn:ietf:params:scim:schemas:core:2.0:User"
groupSchema = "urn:ietf:params:scim:schemas:core:2.0:Group"
listSchema = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
errSchema = "urn:ietf:params:scim:api:messages:2.0:Error"
patchSchema = "urn:ietf:params:scim:api:messages:2.0:PatchOp"
)
// Register mounts the endpoints.
func (s *Server) Register(mux *http.ServeMux) {
h := func(f http.HandlerFunc) http.HandlerFunc { return s.authed(f) }
mux.HandleFunc("GET /scim/v2/ServiceProviderConfig", h(s.config))
mux.HandleFunc("GET /scim/v2/Users", h(s.listUsers))
mux.HandleFunc("POST /scim/v2/Users", h(s.createUser))
mux.HandleFunc("GET /scim/v2/Users/{id}", h(s.getUser))
mux.HandleFunc("PUT /scim/v2/Users/{id}", h(s.replaceUser))
mux.HandleFunc("PATCH /scim/v2/Users/{id}", h(s.patchUser))
mux.HandleFunc("DELETE /scim/v2/Users/{id}", h(s.deleteUser))
mux.HandleFunc("GET /scim/v2/Groups", h(s.listGroups))
mux.HandleFunc("POST /scim/v2/Groups", h(s.createGroup))
mux.HandleFunc("GET /scim/v2/Groups/{id}", h(s.getGroup))
mux.HandleFunc("PATCH /scim/v2/Groups/{id}", h(s.patchGroup))
mux.HandleFunc("PUT /scim/v2/Groups/{id}", h(s.replaceGroup))
mux.HandleFunc("DELETE /scim/v2/Groups/{id}", h(s.deleteGroup))
}
func (s *Server) authed(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
got, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok || s.token == "" || subtle.ConstantTimeCompare([]byte(strings.TrimSpace(got)), []byte(s.token)) != 1 {
fail(w, http.StatusUnauthorized, "", "a bearer token is required")
return
}
next(w, r)
}
}
func write(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/scim+json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func fail(w http.ResponseWriter, code int, scimType, detail string) {
e := map[string]any{"schemas": []string{errSchema}, "status": strconv.Itoa(code), "detail": detail}
if scimType != "" {
e["scimType"] = scimType
}
write(w, code, e)
}
func (s *Server) config(w http.ResponseWriter, _ *http.Request) {
write(w, http.StatusOK, map[string]any{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"},
"patch": map[string]bool{"supported": true}, "bulk": map[string]any{"supported": false},
"filter": map[string]any{"supported": true, "maxResults": 1000}, "changePassword": map[string]bool{"supported": false},
"sort": map[string]bool{"supported": false}, "etag": map[string]bool{"supported": false},
"authenticationSchemes": []map[string]string{{"type": "oauthbearertoken", "name": "Bearer token"}},
})
}
// --- users ---
type userIn struct {
UserName string `json:"userName"`
Name struct {
Formatted string `json:"formatted"`
GivenName string `json:"givenName"`
FamilyName string `json:"familyName"`
} `json:"name"`
DisplayName string `json:"displayName"`
Emails []struct {
Value string `json:"value"`
Primary bool `json:"primary"`
} `json:"emails"`
Active *bool `json:"active"`
}
func (u userIn) email() string {
e := u.UserName
if !strings.Contains(e, "@") {
for _, m := range u.Emails {
if m.Primary || e == "" || !strings.Contains(e, "@") {
e = m.Value
}
}
}
return store.NormalizeEmail(e)
}
func (u userIn) name() string {
switch {
case u.Name.Formatted != "":
return u.Name.Formatted
case u.DisplayName != "":
return u.DisplayName
case u.Name.GivenName != "" || u.Name.FamilyName != "":
return strings.TrimSpace(u.Name.GivenName + " " + u.Name.FamilyName)
}
return ""
}
func userOut(u store.User) map[string]any {
return map[string]any{
"schemas": []string{userSchema}, "id": u.ID, "userName": u.Email,
"name": map[string]string{"formatted": u.Name},
"emails": []map[string]any{{"value": u.Email, "primary": true}},
"active": u.State == store.UserActive,
"meta": map[string]any{"resourceType": "User", "created": u.CreatedAt.UTC().Format(time.RFC3339)},
}
}
var userNameEq = regexp.MustCompile(`(?i)^\s*userName\s+eq\s+"([^"]*)"\s*$`)
func page(r *http.Request, n int) (int, int) {
start, _ := strconv.Atoi(r.URL.Query().Get("startIndex"))
if start < 1 {
start = 1
}
count, err := strconv.Atoi(r.URL.Query().Get("count"))
if err != nil || count < 0 {
count = 100
}
if start-1 > n {
start = n + 1
}
return start, min(count, n-(start-1))
}
func (s *Server) listUsers(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var users []store.User
if f := r.URL.Query().Get("filter"); f != "" {
m := userNameEq.FindStringSubmatch(f)
if m == nil {
fail(w, http.StatusBadRequest, "invalidFilter", `only userName eq "…" is supported`)
return
}
u, err := s.st.UserByEmail(ctx, store.NormalizeEmail(m[1]))
if err != nil && !errors.Is(err, store.ErrNotFound) {
fail(w, http.StatusInternalServerError, "", err.Error())
return
}
if err == nil {
users = []store.User{u}
}
} else {
all, err := s.st.Users(ctx, "")
if err != nil {
fail(w, http.StatusInternalServerError, "", err.Error())
return
}
users = all
}
start, count := page(r, len(users))
res := []map[string]any{}
for _, u := range users[start-1 : start-1+count] {
res = append(res, userOut(u))
}
write(w, http.StatusOK, map[string]any{"schemas": []string{listSchema}, "totalResults": len(users),
"startIndex": start, "itemsPerPage": len(res), "Resources": res})
}
func (s *Server) getUser(w http.ResponseWriter, r *http.Request) {
u, err := s.st.UserByID(r.Context(), r.PathValue("id"))
if err != nil {
fail(w, http.StatusNotFound, "", "no such user")
return
}
write(w, http.StatusOK, userOut(u))
}
func decode(r *http.Request, v any) error {
return json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(v)
}
func newID() string {
var b [10]byte
_, _ = rand.Read(b[:])
return "usr-" + strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b[:]))
}
func (s *Server) createUser(w http.ResponseWriter, r *http.Request) {
var in userIn
if err := decode(r, &in); err != nil {
fail(w, http.StatusBadRequest, "invalidSyntax", err.Error())
return
}
email := in.email()
if !strings.Contains(email, "@") {
fail(w, http.StatusBadRequest, "invalidValue", "userName or an email must be an email address")
return
}
if _, err := s.st.UserByEmail(r.Context(), email); err == nil {
fail(w, http.StatusConflict, "uniqueness", email+" already has an account")
return
}
u := store.User{ID: newID(), Email: email, Name: or(in.name(), email),
// No password works: the provider signs this account in.
PasswordHash: "!scim", State: store.UserActive, CreatedAt: time.Now()}
if in.Active != nil && !*in.Active {
u.State = store.UserSuspended
}
if err := s.st.CreateUser(r.Context(), u); err != nil {
fail(w, http.StatusInternalServerError, "", err.Error())
return
}
s.log.Info("an account was provisioned by the identity provider", "email", email)
write(w, http.StatusCreated, userOut(u))
}
// setActive suspends or reinstates; a suspended account's sessions close.
func (s *Server) setActive(ctx context.Context, u *store.User, active bool) error {
want := store.UserActive
if !active {
want = store.UserSuspended
}
if u.State == want {
return nil
}
if err := s.st.SetUserState(ctx, u.ID, want); err != nil {
return err
}
if !active {
if err := s.st.DeleteUserSessions(ctx, u.ID); err != nil {
return err
}
}
u.State = want
s.log.Info("the identity provider changed an account", "email", u.Email, "state", want)
return nil
}
func (s *Server) replaceUser(w http.ResponseWriter, r *http.Request) {
u, err := s.st.UserByID(r.Context(), r.PathValue("id"))
if err != nil {
fail(w, http.StatusNotFound, "", "no such user")
return
}
var in userIn
if err := decode(r, &in); err != nil {
fail(w, http.StatusBadRequest, "invalidSyntax", err.Error())
return
}
if in.Active != nil {
if err := s.setActive(r.Context(), &u, *in.Active); err != nil {
fail(w, http.StatusInternalServerError, "", err.Error())
return
}
}
write(w, http.StatusOK, userOut(u))
}
type patchOp struct {
Op string `json:"op"`
Path string `json:"path"`
Value json.RawMessage `json:"value"`
}
func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) {
u, err := s.st.UserByID(r.Context(), r.PathValue("id"))
if err != nil {
fail(w, http.StatusNotFound, "", "no such user")
return
}
var in struct{ Operations []patchOp }
if err := decode(r, &in); err != nil {
fail(w, http.StatusBadRequest, "invalidSyntax", err.Error())
return
}
for _, op := range in.Operations {
if active, ok := activeOf(op); ok {
if err := s.setActive(r.Context(), &u, active); err != nil {
fail(w, http.StatusInternalServerError, "", err.Error())
return
}
}
}
write(w, http.StatusOK, userOut(u))
}
// activeOf reads "active" from an operation, written either way providers
// write it: {path: active, value: false} or {value: {active: false}}, the
// value a bool or the string "False".
func activeOf(op patchOp) (bool, bool) {
if !strings.EqualFold(op.Op, "replace") && !strings.EqualFold(op.Op, "add") {
return false, false
}
raw := op.Value
if !strings.EqualFold(op.Path, "active") {
var v map[string]json.RawMessage
if json.Unmarshal(op.Value, &v) != nil {
return false, false
}
var ok bool
if raw, ok = v["active"]; !ok {
return false, false
}
}
var b bool
if json.Unmarshal(raw, &b) == nil {
return b, true
}
var str string
if json.Unmarshal(raw, &str) == nil {
if v, err := strconv.ParseBool(str); err == nil {
return v, true
}
}
return false, false
}
func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) {
u, err := s.st.UserByID(r.Context(), r.PathValue("id"))
if err != nil {
fail(w, http.StatusNotFound, "", "no such user")
return
}
if err := s.setActive(r.Context(), &u, false); err != nil {
fail(w, http.StatusInternalServerError, "", err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- groups ---
// groups are the names the mapping knows, which are the only groups that
// mean anything here.
func (s *Server) groups() []string {
seen := map[string]bool{}
for _, gs := range s.m.Roles {
for _, g := range gs {
seen[g] = true
}
}
for g := range s.m.Memberships {
seen[g] = true
}
out := make([]string, 0, len(seen))
for g := range seen {
out = append(out, g)
}
sort.Strings(out)
return out
}
func groupOut(name string) map[string]any {
return map[string]any{"schemas": []string{groupSchema}, "id": name, "displayName": name,
"meta": map[string]string{"resourceType": "Group"}}
}
func (s *Server) listGroups(w http.ResponseWriter, r *http.Request) {
names := s.groups()
if f := r.URL.Query().Get("filter"); f != "" {
m := regexp.MustCompile(`(?i)^\s*displayName\s+eq\s+"([^"]*)"\s*$`).FindStringSubmatch(f)
if m == nil {
fail(w, http.StatusBadRequest, "invalidFilter", `only displayName eq "…" is supported`)
return
}
names = slices.DeleteFunc(names, func(n string) bool { return n != m[1] })
}
res := []map[string]any{}
for _, n := range names {
res = append(res, groupOut(n))
}
write(w, http.StatusOK, map[string]any{"schemas": []string{listSchema}, "totalResults": len(res),
"startIndex": 1, "itemsPerPage": len(res), "Resources": res})
}
func (s *Server) getGroup(w http.ResponseWriter, r *http.Request) {
if !slices.Contains(s.groups(), r.PathValue("id")) {
fail(w, http.StatusNotFound, "", "no such group: map it under auth.oidc roles or memberships")
return
}
write(w, http.StatusOK, groupOut(r.PathValue("id")))
}
type groupIn struct {
DisplayName string `json:"displayName"`
Members []struct {
Value string `json:"value"`
} `json:"members"`
}
// createGroup answers a provider pushing a group: one the mapping knows is
// followed from now on, its members joined; any other is acknowledged and
// means nothing here.
func (s *Server) createGroup(w http.ResponseWriter, r *http.Request) {
var in groupIn
if err := decode(r, &in); err != nil || in.DisplayName == "" {
fail(w, http.StatusBadRequest, "invalidSyntax", "a group needs a displayName")
return
}
for _, m := range in.Members {
s.member(r.Context(), in.DisplayName, m.Value, true)
}
write(w, http.StatusCreated, groupOut(in.DisplayName))
}
func (s *Server) replaceGroup(w http.ResponseWriter, r *http.Request) {
var in groupIn
if err := decode(r, &in); err != nil {
fail(w, http.StatusBadRequest, "invalidSyntax", err.Error())
return
}
for _, m := range in.Members {
s.member(r.Context(), r.PathValue("id"), m.Value, true)
}
write(w, http.StatusOK, groupOut(r.PathValue("id")))
}
func (s *Server) patchGroup(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("id")
var in struct{ Operations []patchOp }
if err := decode(r, &in); err != nil {
fail(w, http.StatusBadRequest, "invalidSyntax", err.Error())
return
}
for _, op := range in.Operations {
add := strings.EqualFold(op.Op, "add") || strings.EqualFold(op.Op, "replace")
remove := strings.EqualFold(op.Op, "remove")
var members []struct {
Value string `json:"value"`
}
_ = json.Unmarshal(op.Value, &members)
// remove with a path: members[value eq "id"]
if remove && len(members) == 0 {
if m := regexp.MustCompile(`value eq "([^"]+)"`).FindStringSubmatch(op.Path); m != nil {
members = append(members, struct {
Value string `json:"value"`
}{m[1]})
}
}
if !strings.HasPrefix(strings.ToLower(op.Path), "members") && op.Path != "" {
continue
}
for _, m := range members {
if add || remove {
s.member(r.Context(), name, m.Value, add)
}
}
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) deleteGroup(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// member applies a person joining or leaving a group: the staff role it
// gives, and the place in the tree.
func (s *Server) member(ctx context.Context, group, userID string, joined bool) {
u, err := s.st.UserByID(ctx, userID)
if err != nil {
s.log.Warn("a group change names a user the gateway does not have", "group", group, "user", userID)
return
}
for _, role := range []string{store.RoleAdmin, store.RoleOperator, store.RoleBilling, store.RoleViewer} {
if !slices.Contains(s.m.Roles[role], group) {
continue
}
switch {
case joined && roleRank(role) > roleRank(u.StaffRole()):
_ = s.st.SetStaffRole(ctx, u.ID, role)
case !joined && u.StaffRole() == role:
_ = s.st.SetStaffRole(ctx, u.ID, "")
}
}
where, ok := s.m.Memberships[group]
if !ok {
return
}
scope, id, err := s.place(ctx, where)
if err != nil {
s.log.Warn("a group maps to a place the tree does not have", "group", group, "place", where, "err", err)
return
}
if joined {
err = s.st.AddMembership(ctx, u.ID, scope, id, store.RoleMember, "scim", time.Now())
} else {
_, err = s.st.RemoveMembership(ctx, u.ID, scope, id, time.Now())
}
if err != nil {
s.log.Warn("a group's membership could not be applied", "group", group, "user", u.Email, "err", err)
}
}
func roleRank(r string) int {
return map[string]int{store.RoleViewer: 1, store.RoleBilling: 2, store.RoleOperator: 3, store.RoleAdmin: 4}[r]
}
func (s *Server) place(ctx context.Context, where string) (store.Scope, string, error) {
kind, path, _ := strings.Cut(where, ":")
// The store writes paths "Acme / ml / train"; a mapping writes them
// either way.
norm := func(s string) string {
parts := strings.Split(s, "/")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
return strings.Join(parts, "/")
}
same := func(a string) bool { return strings.EqualFold(norm(a), norm(path)) }
switch kind {
case "org":
orgs, err := s.st.AllOrgs(ctx)
for _, o := range orgs {
if same(o.Name) {
return store.ScopeOrg, o.ID, nil
}
}
return "", "", errors.Join(err, fmt.Errorf("no organization %s", path))
case "team":
teams, err := s.st.AllTeams(ctx)
for _, t := range teams {
if same(t.OrgName + "/" + t.Name) {
return store.ScopeTeam, t.ID, nil
}
}
return "", "", errors.Join(err, fmt.Errorf("no team %s", path))
default:
projects, err := s.st.AllProjects(ctx)
for _, p := range projects {
if same(p.Path) {
return store.ScopeProject, p.ID, nil
}
}
return "", "", errors.Join(err, fmt.Errorf("no project %s", path))
}
}
func or(s, def string) string {
if s == "" {
return def
}
return s
}