416 lines
13 KiB
Go
416 lines
13 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"agent-system-backend/internal/store"
|
|
"agent-system-backend/pkg/extension"
|
|
)
|
|
|
|
type accountDirectorySlot struct {
|
|
mu sync.RWMutex
|
|
pluginID string
|
|
resolver extension.AccountDirectoryScopeResolver
|
|
secret [32]byte
|
|
keyVersion int
|
|
}
|
|
|
|
func newAccountDirectorySlot(st *store.Store) (*accountDirectorySlot, error) {
|
|
key, err := st.GetAccountCursorKey()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &accountDirectorySlot{secret: key.Secret, keyVersion: key.Version}, nil
|
|
}
|
|
|
|
func (s *accountDirectorySlot) register(pluginID string, fn extension.AccountDirectoryScopeResolver) error {
|
|
if fn == nil || pluginID != "rdm" {
|
|
return store.ErrAccountForbidden
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.resolver != nil {
|
|
return errors.New("account directory resolver already registered")
|
|
}
|
|
s.pluginID, s.resolver = pluginID, fn
|
|
return nil
|
|
}
|
|
|
|
type directoryPolicy struct {
|
|
allowed map[string]bool
|
|
match []string
|
|
candidates map[string]bool
|
|
owners map[string]bool
|
|
rows []store.DirectoryAccountRow
|
|
all bool
|
|
status string
|
|
version string
|
|
}
|
|
|
|
type directoryCursor struct {
|
|
KeyVersion int `json:"k"`
|
|
Version string `json:"v"`
|
|
Owner string `json:"o"`
|
|
UserID string `json:"u"`
|
|
}
|
|
|
|
type directoryVisibleRow struct {
|
|
UserID string
|
|
Owner string
|
|
Enabled bool
|
|
Name string
|
|
StaffNo string
|
|
}
|
|
|
|
func knownDirectoryField(field string) bool {
|
|
return field == "owner" || field == "name" || field == "staffNo"
|
|
}
|
|
|
|
func (h inprocHost) directoryPolicy(ctx context.Context, q extension.AccountDirectoryQuery, loadRows bool) (directoryPolicy, error) {
|
|
var p directoryPolicy
|
|
if err := h.requireEffective(); err != nil {
|
|
return p, err
|
|
}
|
|
if h.actor == "" || h.caps.st == nil || h.caps.directory == nil || q.SchemaVersion != extension.AccountDirectorySchemaVersion {
|
|
return p, store.ErrAccountForbidden
|
|
}
|
|
actor, err := h.caps.st.GetUserAuthState(h.actor)
|
|
if err != nil || !actor.Enabled {
|
|
return p, store.ErrAccountForbidden
|
|
}
|
|
if q.Mode != extension.AccountDirectoryModeExact && q.Mode != extension.AccountDirectoryModeFuzzy {
|
|
return p, store.ErrInvalidAccountProfile
|
|
}
|
|
if !utf8.ValidString(q.Query) || utf8.RuneCountInString(q.Query) > 128 {
|
|
return p, store.ErrInvalidAccountProfile
|
|
}
|
|
for _, r := range q.Query {
|
|
if unicode.IsControl(r) {
|
|
return p, store.ErrInvalidAccountProfile
|
|
}
|
|
}
|
|
p.status = q.Status
|
|
if p.status == "" {
|
|
p.status = "enabled"
|
|
}
|
|
if p.status != "enabled" && p.status != "disabled" && p.status != "all" {
|
|
return p, store.ErrInvalidAccountProfile
|
|
}
|
|
var scope extension.AccountDirectoryScope
|
|
switch q.Purpose {
|
|
case extension.AccountDirectoryPurposeSystemUsers:
|
|
if !actor.IsAdmin || q.ProjectID != "" || q.DocumentID != "" {
|
|
return p, store.ErrAccountForbidden
|
|
}
|
|
scope = extension.AccountDirectoryScope{AllowedFields: []string{"owner", "name", "staffNo"},
|
|
MatchFields: []string{"owner", "name", "staffNo"}, AllCandidates: true, ScopeVersion: "system.users.v1"}
|
|
case extension.AccountDirectoryPurposeProjectMembers, extension.AccountDirectoryPurposeProjectRoster, extension.AccountDirectoryPurposeDocumentDuties:
|
|
// project.member-roster is an explicit enumeration of current project
|
|
// members: only the trusted RDM resolver may name them, every account
|
|
// status is allowed and an empty query lists the whole roster.
|
|
roster := q.Purpose == extension.AccountDirectoryPurposeProjectRoster
|
|
if h.pluginID != "rdm" || q.ProjectID == "" || (q.Purpose == extension.AccountDirectoryPurposeDocumentDuties && q.DocumentID == "") ||
|
|
(q.Purpose == extension.AccountDirectoryPurposeProjectMembers && q.DocumentID != "") || (roster && q.DocumentID != "") ||
|
|
(!roster && p.status != "enabled") {
|
|
return p, store.ErrAccountForbidden
|
|
}
|
|
h.caps.directory.mu.RLock()
|
|
resolver, pluginID := h.caps.directory.resolver, h.caps.directory.pluginID
|
|
h.caps.directory.mu.RUnlock()
|
|
if resolver == nil || pluginID != h.pluginID {
|
|
return p, store.ErrAccountForbidden
|
|
}
|
|
scope, err = resolver(ctx, extension.AccountDirectoryScopeRequest{
|
|
Actor: h.actor, PluginID: h.pluginID, Purpose: q.Purpose, ProjectID: q.ProjectID, DocumentID: q.DocumentID,
|
|
})
|
|
if err != nil {
|
|
return p, store.ErrAccountForbidden
|
|
}
|
|
if (q.Purpose == extension.AccountDirectoryPurposeDocumentDuties || roster) && scope.AllCandidates {
|
|
return p, store.ErrAccountForbidden
|
|
}
|
|
default:
|
|
return p, store.ErrAccountForbidden
|
|
}
|
|
if scope.ScopeVersion == "" {
|
|
return p, store.ErrAccountForbidden
|
|
}
|
|
p.allowed = map[string]bool{"owner": true}
|
|
for _, field := range scope.AllowedFields {
|
|
if !knownDirectoryField(field) {
|
|
return p, store.ErrAccountForbidden
|
|
}
|
|
p.allowed[field] = true
|
|
}
|
|
matchAllowed := map[string]bool{}
|
|
for _, field := range scope.MatchFields {
|
|
if !knownDirectoryField(field) || !p.allowed[field] {
|
|
return p, store.ErrAccountForbidden
|
|
}
|
|
matchAllowed[field] = true
|
|
}
|
|
requested := q.SearchFields
|
|
if len(requested) == 0 {
|
|
requested = []string{"owner", "name"}
|
|
}
|
|
for _, field := range requested {
|
|
if !matchAllowed[field] {
|
|
return p, store.ErrAccountForbidden
|
|
}
|
|
p.match = append(p.match, field)
|
|
}
|
|
sort.Strings(p.match)
|
|
p.candidates = make(map[string]bool, len(scope.CandidateIDs))
|
|
p.owners = make(map[string]bool, len(scope.CandidateOwners))
|
|
for _, id := range scope.CandidateIDs {
|
|
if id == "" {
|
|
return p, store.ErrAccountForbidden
|
|
}
|
|
p.candidates[id] = true
|
|
}
|
|
for _, owner := range scope.CandidateOwners {
|
|
if owner == "" {
|
|
return p, store.ErrAccountForbidden
|
|
}
|
|
p.owners[owner] = true
|
|
}
|
|
p.all = scope.AllCandidates
|
|
if q.Purpose == extension.AccountDirectoryPurposeSystemUsers {
|
|
p.all = true
|
|
}
|
|
visible := make([]directoryVisibleRow, 0)
|
|
if loadRows {
|
|
rows, err := h.caps.st.ListDirectoryAccountRows()
|
|
if err != nil {
|
|
return p, err
|
|
}
|
|
for _, row := range rows {
|
|
if !directoryRowAllowed(row, p) {
|
|
continue
|
|
}
|
|
p.rows = append(p.rows, row)
|
|
item := directoryVisibleRow{UserID: row.UserID, Owner: row.Owner, Enabled: row.Enabled}
|
|
if p.allowed["name"] {
|
|
item.Name = row.Name
|
|
}
|
|
if p.allowed["staffNo"] {
|
|
item.StaffNo = row.StaffNo
|
|
}
|
|
visible = append(visible, item)
|
|
}
|
|
}
|
|
ids := make([]string, 0, len(p.candidates))
|
|
for id := range p.candidates {
|
|
ids = append(ids, id)
|
|
}
|
|
sort.Strings(ids)
|
|
owners := make([]string, 0, len(p.owners))
|
|
for owner := range p.owners {
|
|
owners = append(owners, owner)
|
|
}
|
|
sort.Strings(owners)
|
|
allowed := make([]string, 0, len(p.allowed))
|
|
for field := range p.allowed {
|
|
allowed = append(allowed, field)
|
|
}
|
|
sort.Strings(allowed)
|
|
fingerprint := struct {
|
|
Actor, Plugin, Purpose, Project, Document, Mode, Query, Status, Scope string
|
|
Match, Allowed, IDs, Owners []string
|
|
Visible []directoryVisibleRow
|
|
All bool
|
|
}{Actor: h.actor, Plugin: h.pluginID, Purpose: q.Purpose, Project: q.ProjectID, Document: q.DocumentID,
|
|
Mode: q.Mode, Query: strings.TrimSpace(q.Query), Status: p.status,
|
|
Scope: scope.ScopeVersion, Match: p.match, Allowed: allowed, IDs: ids, Owners: owners, Visible: visible, All: p.all}
|
|
encoded, err := json.Marshal(fingerprint)
|
|
if err != nil {
|
|
return p, err
|
|
}
|
|
digest := sha256.Sum256(encoded)
|
|
p.version = hex.EncodeToString(digest[:])
|
|
return p, nil
|
|
}
|
|
|
|
func (s *accountDirectorySlot) encodeCursor(c directoryCursor) (string, error) {
|
|
c.KeyVersion = s.keyVersion
|
|
data, err := json.Marshal(c)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
mac := hmac.New(sha256.New, s.secret[:])
|
|
_, _ = mac.Write([]byte("iam-directory-cursor-v1\x00"))
|
|
_, _ = mac.Write(data)
|
|
return base64.RawURLEncoding.EncodeToString(data) + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
|
|
}
|
|
|
|
func (s *accountDirectorySlot) decodeCursor(token, version string) (directoryCursor, error) {
|
|
var c directoryCursor
|
|
payload, signature, found := strings.Cut(token, ".")
|
|
if !found {
|
|
return c, store.ErrConflict
|
|
}
|
|
data, err := base64.RawURLEncoding.DecodeString(payload)
|
|
if err != nil || len(data) > 1024 {
|
|
return c, store.ErrConflict
|
|
}
|
|
provided, err := base64.RawURLEncoding.DecodeString(signature)
|
|
if err != nil {
|
|
return c, store.ErrConflict
|
|
}
|
|
mac := hmac.New(sha256.New, s.secret[:])
|
|
_, _ = mac.Write([]byte("iam-directory-cursor-v1\x00"))
|
|
_, _ = mac.Write(data)
|
|
if !hmac.Equal(provided, mac.Sum(nil)) || json.Unmarshal(data, &c) != nil || c.KeyVersion != s.keyVersion || c.Version != version || c.Owner == "" || c.UserID == "" {
|
|
return directoryCursor{}, store.ErrConflict
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func directoryEntry(row store.DirectoryAccountRow, p directoryPolicy) extension.AccountDirectoryEntry {
|
|
entry := extension.AccountDirectoryEntry{UserID: row.UserID, Owner: row.Owner, Valid: row.Enabled}
|
|
if p.allowed["name"] {
|
|
entry.Name = row.Name
|
|
}
|
|
if p.allowed["staffNo"] {
|
|
entry.StaffNo = row.StaffNo
|
|
}
|
|
return entry
|
|
}
|
|
|
|
func directoryMatches(row store.DirectoryAccountRow, fields []string, mode, query string) bool {
|
|
for _, field := range fields {
|
|
value := row.Owner
|
|
switch field {
|
|
case "name":
|
|
value = row.Name
|
|
case "staffNo":
|
|
value = row.StaffNo
|
|
}
|
|
if mode == extension.AccountDirectoryModeExact && strings.EqualFold(value, query) {
|
|
return true
|
|
}
|
|
if mode == extension.AccountDirectoryModeFuzzy && strings.Contains(strings.ToLower(value), strings.ToLower(query)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func directoryRowAllowed(row store.DirectoryAccountRow, p directoryPolicy) bool {
|
|
if !p.all && !p.candidates[row.UserID] && !p.owners[row.Owner] {
|
|
return false
|
|
}
|
|
return p.status == "all" || (p.status == "enabled" && row.Enabled) || (p.status == "disabled" && !row.Enabled)
|
|
}
|
|
|
|
func (h inprocHost) QueryAccountDirectory(ctx context.Context, q extension.AccountDirectoryQuery) (extension.AccountDirectoryPage, error) {
|
|
query := strings.TrimSpace(q.Query)
|
|
// project.member-roster enumerates the resolved members: an empty query
|
|
// lists every row inside the scope with normal limit/cursor paging.
|
|
enumerate := query == "" && q.Purpose == extension.AccountDirectoryPurposeProjectRoster
|
|
p, err := h.directoryPolicy(ctx, q, query != "" || enumerate)
|
|
if err != nil {
|
|
return extension.AccountDirectoryPage{}, err
|
|
}
|
|
maxLimit := 50
|
|
if q.Purpose == extension.AccountDirectoryPurposeSystemUsers {
|
|
maxLimit = 100
|
|
}
|
|
if q.Limit == 0 {
|
|
q.Limit = 20
|
|
}
|
|
if q.Limit < 1 || q.Limit > maxLimit {
|
|
return extension.AccountDirectoryPage{}, store.ErrInvalidAccountProfile
|
|
}
|
|
page := extension.AccountDirectoryPage{Entries: []extension.AccountDirectoryEntry{}, DirectoryVersion: p.version}
|
|
if query == "" && !enumerate {
|
|
if q.Cursor != "" {
|
|
return extension.AccountDirectoryPage{}, store.ErrConflict
|
|
}
|
|
return page, nil
|
|
}
|
|
if query != "" && q.Mode == extension.AccountDirectoryModeFuzzy && utf8.RuneCountInString(query) < 2 {
|
|
return extension.AccountDirectoryPage{}, store.ErrInvalidAccountProfile
|
|
}
|
|
var after directoryCursor
|
|
if q.Cursor != "" {
|
|
after, err = h.caps.directory.decodeCursor(q.Cursor, p.version)
|
|
if err != nil {
|
|
return extension.AccountDirectoryPage{}, err
|
|
}
|
|
}
|
|
matched := make([]store.DirectoryAccountRow, 0, q.Limit+1)
|
|
for _, row := range p.rows {
|
|
if !enumerate && !directoryMatches(row, p.match, q.Mode, query) {
|
|
continue
|
|
}
|
|
if after.Owner != "" && (row.Owner < after.Owner || (row.Owner == after.Owner && row.UserID <= after.UserID)) {
|
|
continue
|
|
}
|
|
matched = append(matched, row)
|
|
if len(matched) > q.Limit {
|
|
break
|
|
}
|
|
}
|
|
more := len(matched) > q.Limit
|
|
if more {
|
|
matched = matched[:q.Limit]
|
|
}
|
|
for _, row := range matched {
|
|
page.Entries = append(page.Entries, directoryEntry(row, p))
|
|
}
|
|
if more {
|
|
last := matched[len(matched)-1]
|
|
page.NextCursor, err = h.caps.directory.encodeCursor(directoryCursor{Version: p.version, Owner: last.Owner, UserID: last.UserID})
|
|
if err != nil {
|
|
return extension.AccountDirectoryPage{}, err
|
|
}
|
|
}
|
|
return page, nil
|
|
}
|
|
|
|
func (h inprocHost) LookupAccountDirectory(ctx context.Context, q extension.AccountDirectoryQuery, userID string) (extension.AccountDirectoryLookup, error) {
|
|
if userID == "" || q.Cursor != "" {
|
|
return extension.AccountDirectoryLookup{}, store.ErrInvalidAccountProfile
|
|
}
|
|
p, err := h.directoryPolicy(ctx, q, true)
|
|
if err != nil {
|
|
return extension.AccountDirectoryLookup{}, err
|
|
}
|
|
for _, row := range p.rows {
|
|
if row.UserID == userID {
|
|
return extension.AccountDirectoryLookup{Entry: directoryEntry(row, p), DirectoryVersion: p.version}, nil
|
|
}
|
|
}
|
|
return extension.AccountDirectoryLookup{}, store.ErrNotFound
|
|
}
|
|
|
|
func (h inprocHost) LookupAccountDirectoryOwner(ctx context.Context, q extension.AccountDirectoryQuery, owner string) (extension.AccountDirectoryLookup, error) {
|
|
if owner == "" || q.Cursor != "" {
|
|
return extension.AccountDirectoryLookup{}, store.ErrInvalidAccountProfile
|
|
}
|
|
p, err := h.directoryPolicy(ctx, q, true)
|
|
if err != nil {
|
|
return extension.AccountDirectoryLookup{}, err
|
|
}
|
|
for _, row := range p.rows {
|
|
if row.Owner == owner {
|
|
return extension.AccountDirectoryLookup{Entry: directoryEntry(row, p), DirectoryVersion: p.version}, nil
|
|
}
|
|
}
|
|
return extension.AccountDirectoryLookup{}, store.ErrNotFound
|
|
}
|
|
|
|
var _ extension.AccountDirectoryProvider = inprocHost{}
|