Files
llmgw/internal/store/memberships.go
T
Ilker ManapandClaude Opus 5.5 27c49d0af9 Refuse the keys of an account that is not active
Resolve now checks the owner's state for a personal key; the authenticator
treats an inactive owner as a revoked key. Suspension stops the keys within
the cache TTL, reinstatement brings them back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 10:15:32 +02:00

322 lines
11 KiB
Go

package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// ErrNotMember means a person asked to use a project they are not in.
var ErrNotMember = errors.New("not a member of that project")
// Roles a membership can carry.
const (
RoleMember = "member"
// RoleManager is reserved for delegated administration of a scope.
RoleManager = "manager"
)
// Membership is a person's place at one level of the tree.
type Membership struct {
UserID string
Scope Scope // org, team or project
ScopeID string
Role string
// Path is where it is: "Acme", "Acme / ml", "Acme / ml / training".
Path string
AddedAt time.Time
AddedBy string
}
func validMembershipScope(s Scope) bool {
return s == ScopeOrg || s == ScopeTeam || s == ScopeProject
}
// AddMembership puts a person in an organization, team or project. Adding one
// that exists changes nothing but its role.
func (s *DB) AddMembership(ctx context.Context, userID string, scope Scope, scopeID, role, by string, at time.Time) error {
if !validMembershipScope(scope) {
return fmt.Errorf("a membership is in an org, team or project, not %q", scope)
}
if role == "" {
role = RoleMember
}
table := map[Scope]string{ScopeOrg: "organizations", ScopeTeam: "teams", ScopeProject: "projects"}[scope]
if err := s.exists(ctx, string(scope), `SELECT COUNT(*) FROM `+table+` WHERE id = ?`, scopeID); err != nil {
return err
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO memberships (user_id, scope_type, scope_id, role, added_at, added_by)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (user_id, scope_type, scope_id) DO UPDATE SET role = excluded.role`,
userID, string(scope), scopeID, role, millis(at), nullString(by))
return err
}
// RemoveMembership takes a person out of a scope and revokes the keys they
// made in any project they can no longer use, returning how many.
//
// Revoked rather than turned personal: a key made in a project spends that
// project's budget, and quietly moving it to the person would carry access
// out of the project the operator just removed them from.
func (s *DB) RemoveMembership(ctx context.Context, userID string, scope Scope, scopeID string, at time.Time) (int, error) {
var revoked int
err := s.inTx(ctx, func(c conn) error {
res, err := c.ExecContext(ctx,
`DELETE FROM memberships WHERE user_id = ? AND scope_type = ? AND scope_id = ?`,
userID, string(scope), scopeID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
res, err = c.ExecContext(ctx, `
UPDATE api_keys SET revoked_at = ?
WHERE user_id = ? AND project_id IS NOT NULL AND revoked_at IS NULL
AND project_id NOT IN (`+usableProjects+`)`,
millis(at), userID, userID, userID, userID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
revoked = int(n)
return nil
})
return revoked, err
}
// usableProjects selects every project a person may use: those they are a
// member of, and every project under a team or organization they are a member
// of. Takes the user id three times.
const usableProjects = `
SELECT m.scope_id FROM memberships m
WHERE m.user_id = ? AND m.scope_type = 'project'
UNION
SELECT p.id FROM projects p JOIN memberships m ON m.scope_type = 'team' AND m.scope_id = p.team_id
WHERE m.user_id = ?
UNION
SELECT p.id FROM projects p JOIN teams t ON t.id = p.team_id
JOIN memberships m ON m.scope_type = 'org' AND m.scope_id = t.org_id
WHERE m.user_id = ?`
// CanUseProject reports whether a person may make and use keys in a project.
func (s *DB) CanUseProject(ctx context.Context, userID, projectID string) (bool, error) {
var n int
err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM (`+usableProjects+`) u WHERE u.scope_id = ?`,
userID, userID, userID, projectID).Scan(&n)
return n > 0, err
}
// ProjectsFor lists the projects a person may use, with their paths.
func (s *DB) ProjectsFor(ctx context.Context, userID string) ([]ProjectPath, error) {
return queryAll(ctx, s.db, `
SELECT p.id, o.name || ' / ' || t.name || ' / ' || p.name
FROM projects p
JOIN teams t ON t.id = p.team_id
JOIN organizations o ON o.id = t.org_id
WHERE p.id IN (`+usableProjects+`)
ORDER BY 2`, []any{userID, userID, userID}, func(rows *sql.Rows) (ProjectPath, error) {
var p ProjectPath
return p, rows.Scan(&p.ID, &p.Path)
})
}
// membershipPath names the scope of a membership row as a path.
const membershipPath = `
CASE m.scope_type
WHEN 'org' THEN (SELECT o.name FROM organizations o WHERE o.id = m.scope_id)
WHEN 'team' THEN (SELECT o.name || ' / ' || t.name FROM teams t
JOIN organizations o ON o.id = t.org_id WHERE t.id = m.scope_id)
ELSE (SELECT o.name || ' / ' || t.name || ' / ' || p.name FROM projects p
JOIN teams t ON t.id = p.team_id JOIN organizations o ON o.id = t.org_id
WHERE p.id = m.scope_id)
END`
func scanMembership(rows *sql.Rows) (Membership, error) {
var m Membership
var scope string
var path, by sql.NullString
var added int64
err := rows.Scan(&m.UserID, &scope, &m.ScopeID, &m.Role, &added, &by, &path)
m.Scope, m.Path, m.AddedBy, m.AddedAt = Scope(scope), path.String, by.String, fromMillis(added)
return m, err
}
// MembershipsOf lists where a person is a member, by path.
func (s *DB) MembershipsOf(ctx context.Context, userID string) ([]Membership, error) {
return queryAll(ctx, s.db, `
SELECT m.user_id, m.scope_type, m.scope_id, m.role, m.added_at, m.added_by, `+membershipPath+`
FROM memberships m WHERE m.user_id = ? ORDER BY 7`, []any{userID}, scanMembership)
}
// AllMemberships lists every membership, for a page that shows the tree with
// its people.
func (s *DB) AllMemberships(ctx context.Context) ([]Membership, error) {
return queryAll(ctx, s.db, `
SELECT m.user_id, m.scope_type, m.scope_id, m.role, m.added_at, m.added_by, `+membershipPath+`
FROM memberships m ORDER BY 7`, nil, scanMembership)
}
// SetPersonalDefaults sets the limits a person's own use falls under when
// nobody wrote a limit for that person: what a personal key, with no project
// to be bounded by, gets. Nil clears them.
func (s *DB) SetPersonalDefaults(l *Limits) {
s.mu.Lock()
defer s.mu.Unlock()
if l == nil {
s.personal = nil
return
}
c := *l
s.personal = &c
}
// Resolve walks a key's chain and returns the most restrictive of each limit.
func (s *DB) Resolve(ctx context.Context, keyID string) (Effective, error) {
var e Effective
var band, profile, project, user sql.NullString
err := s.db.QueryRowContext(ctx,
`SELECT id, band, routing_profile, project_id, user_id FROM api_keys WHERE id = ?`, keyID).
Scan(&e.KeyID, &band, &profile, &project, &user)
if errors.Is(err, sql.ErrNoRows) {
return Effective{}, ErrNotFound
}
if err != nil {
return Effective{}, err
}
e.Band, e.RoutingProfile = band.String, profile.String
e.ProjectID, e.UserID = project.String, user.String
// A person's key works while the person's account does: suspending an
// account closed its sessions and left every key it had made working.
if e.UserID != "" {
var state string
err := s.db.QueryRowContext(ctx, `SELECT state FROM users WHERE id = ?`, e.UserID).Scan(&state)
if errors.Is(err, sql.ErrNoRows) || (err == nil && UserState(state) != UserActive) {
return Effective{}, ErrOwnerInactive
}
if err != nil {
return Effective{}, err
}
}
return e, s.resolveChain(ctx, &e, true)
}
// ErrOwnerInactive is a key whose owner's account is suspended, pending, or
// gone.
var ErrOwnerInactive = errors.New("the key's owner is not an active account")
// ResolveFor resolves a caller that has no key row: the chat page, spending
// as a person in a project or, with no project, personally.
func (s *DB) ResolveFor(ctx context.Context, callerID, userID, projectID string) (Effective, error) {
e := Effective{KeyID: callerID, UserID: userID, ProjectID: projectID}
return e, s.resolveChain(ctx, &e, false)
}
// resolveChain fills in the tree above a caller and folds every limit that
// applies: the project's chain, each per-member limit on it for this person,
// the person's own, and the key's.
//
// One pool per level. A budget on a team is spent by everything beneath it; a
// person's limit by every key they own; a per-member limit by one person
// within one scope.
func (s *DB) resolveChain(ctx context.Context, e *Effective, keyLevel bool) error {
if e.ProjectID != "" {
err := s.db.QueryRowContext(ctx,
`SELECT t.id, t.org_id FROM projects p JOIN teams t ON t.id = p.team_id WHERE p.id = ?`,
e.ProjectID).Scan(&e.TeamID, &e.OrgID)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
}
type step struct {
scope Scope
id string
// counter is where use is counted, when it is not the scope's own id.
counter string
}
var steps []step
if e.ProjectID != "" {
steps = append(steps, step{ScopeOrg, e.OrgID, ""}, step{ScopeTeam, e.TeamID, ""},
step{ScopeProject, e.ProjectID, ""})
if e.UserID != "" {
for _, lvl := range []step{{ScopeOrg, e.OrgID, ""}, {ScopeTeam, e.TeamID, ""}, {ScopeProject, e.ProjectID, ""}} {
ms := MemberScope(lvl.scope)
steps = append(steps, step{ms, lvl.id, MemberOwner(ms, lvl.id, e.UserID).ID})
}
}
}
if e.UserID != "" {
steps = append(steps, step{ScopeUser, e.UserID, ""})
}
if keyLevel {
steps = append(steps, step{ScopeKey, e.KeyID, ""})
}
sawUser := false
for _, st := range steps {
l, err := s.LimitsFor(ctx, st.scope, st.id)
if errors.Is(err, ErrNotFound) {
continue
}
if err != nil {
return err
}
if st.counter != "" {
l.ScopeID = st.counter
}
if st.scope == ScopeUser {
sawUser = true
}
e.tighten(l)
// Kept whole as well as folded in. Enforcement walks this; the folded
// values are for showing somebody their ceiling.
e.Chain = append(e.Chain, l)
}
// A personal caller has no project to be bounded by. With no limit written
// for the person, the personal defaults stand in for one — counted in the
// person's own pool, so every personal key they hold shares it.
if e.ProjectID == "" && e.UserID != "" && !sawUser {
s.mu.RLock()
p := s.personal
s.mu.RUnlock()
if p != nil {
l := *p
l.Scope, l.ScopeID = ScopeUser, e.UserID
e.tighten(l)
e.Chain = append(e.Chain, l)
}
}
s.applyDefaults(e)
return nil
}
// ManagedOrgs lists the organizations a person manages.
func (s *DB) ManagedOrgs(ctx context.Context, userID string) ([]Org, error) {
return queryAll(ctx, s.db, `
SELECT o.id, o.name FROM memberships m JOIN organizations o ON o.id = m.scope_id
WHERE m.user_id = ? AND m.scope_type = 'org' AND m.role = 'manager'
ORDER BY o.name`, []any{userID}, func(rows *sql.Rows) (Org, error) {
var o Org
return o, rows.Scan(&o.ID, &o.Name)
})
}
// ManagesOrg reports whether a person manages an organization.
func (s *DB) ManagesOrg(ctx context.Context, userID, orgID string) (bool, error) {
var n int
err := s.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM memberships
WHERE user_id = ? AND scope_type = 'org' AND scope_id = ? AND role = 'manager'`,
userID, orgID).Scan(&n)
return n > 0, err
}