C1. A membership of an organization may be admin as well as member and manager. An admin sees /org as a manager does and changes the organization from it: its teams and projects, who of the approved accounts is in them, its members' roles, and the keys spent in its projects. Every id a form names is checked against the organization; limits, accounts, the organization's name and the cluster stay with the cluster's staff, and an admin cannot take their own role away. A group from OIDC, SCIM or a directory no longer rewrites a membership it names as a plain member at every sign-in, which took a manager's role away (EnsureMembership). Pages draw a form only for someone who may send it: Nav.May asks the staff rule staffMay keeps, and a page whose forms a person may not send gets no form token. A viewer sees the people, limits and mirror pages without forms; an operator the mirror's. Forms are compact: labels close to their fields, fields as wide as what goes in them, smaller buttons, and the short forms on one line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
610 lines
18 KiB
Go
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
|
|
EnsureMembership(ctx context.Context, user string, scope store.Scope, id, 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.EnsureMembership(ctx, u.ID, scope, id, "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
|
|
}
|