internal/server/store/identity.go
Ref: Size: 4.6 KiB History
package store
import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
sqlite "modernc.org/sqlite"
)
// Tenant is one partition of the fleet. In v1 a tenant maps 1:1 to a signed-in
// identity; the OIDC binding (issuer, subject) lives directly on the row —
// there is no separate users table until multi-user tenants exist. Every
// tenant is born bound: the only creation path is JIT provisioning on first
// sign-in (CreateTenantForIdentity).
type Tenant struct {
ID, Name string
CreatedAt time.Time
OIDCIssuer string
OIDCSubject string
Email string
}
const tenantColumns = `id, name, created_at, oidc_issuer, oidc_subject, email`
// scanTenant reads a tenantColumns-projected row (positional, must match).
type rowScanner interface {
Scan(dest ...any) error
}
func scanTenant(sc rowScanner) (Tenant, error) {
var tn Tenant
var created string
if err := sc.Scan(&tn.ID, &tn.Name, &created, &tn.OIDCIssuer, &tn.OIDCSubject, &tn.Email); err != nil {
return Tenant{}, err
}
tn.CreatedAt, _ = time.Parse(time.RFC3339, created)
return tn, nil
}
// TenantByIdentity looks up the tenant bound to (issuer, subject) — the durable
// identity key (never email, which changes). ok=false with no error means no
// tenant is bound to that identity yet (the JIT-provision trigger).
func (s *Store) TenantByIdentity(issuer, subject string) (Tenant, bool, error) {
tn, err := scanTenant(s.db.QueryRow(
`SELECT `+tenantColumns+` FROM tenants WHERE oidc_issuer=? AND oidc_subject=?`, issuer, subject))
if errors.Is(err, sql.ErrNoRows) {
return Tenant{}, false, nil
}
if err != nil {
return Tenant{}, false, fmt.Errorf("tenant by identity: %w", err)
}
return tn, true, nil
}
// TenantByID looks up a tenant by its handle (the primary key). ok=false with
// no error means no such tenant. Handlers use it to read identity fields off
// the tenant row — email for GET /api/v1/me — once the middleware has resolved
// the caller's tenant from a PAT or session.
func (s *Store) TenantByID(id string) (Tenant, bool, error) {
tn, err := scanTenant(s.db.QueryRow(
`SELECT `+tenantColumns+` FROM tenants WHERE id=?`, id))
if errors.Is(err, sql.ErrNoRows) {
return Tenant{}, false, nil
}
if err != nil {
return Tenant{}, false, fmt.Errorf("tenant by id: %w", err)
}
return tn, true, nil
}
// CreateTenantForIdentity JIT-provisions a fresh tenant bound to (issuer,
// subject). The handle derives from the email local part (handleFromEmail),
// with a numeric suffix resolving collisions; SystemTenant is reserved and
// never assigned (a principal holding it could read the system audit scope).
// A second create for an identity that already owns a tenant is rejected (the
// identity unique index).
func (s *Store) CreateTenantForIdentity(issuer, subject, email string) (Tenant, error) {
tx, err := s.db.Begin()
if err != nil {
return Tenant{}, err
}
defer tx.Rollback()
base := handleFromEmail(email)
now := time.Now().UTC().Format(time.RFC3339)
for attempt := 1; ; attempt++ {
candidate := base
if attempt > 1 {
candidate = fmt.Sprintf("%s-%d", base, attempt)
}
if candidate == SystemTenant {
continue // reserved; the next suffix wins
}
_, err := tx.Exec(
`INSERT INTO tenants(id, name, created_at, oidc_issuer, oidc_subject, email) VALUES (?,?,?,?,?,?)`,
candidate, candidate, now, issuer, subject, email,
)
if err == nil {
if err := tx.Commit(); err != nil {
return Tenant{}, err
}
return Tenant{
ID: candidate, Name: candidate,
CreatedAt: time.Now().UTC(),
OIDCIssuer: issuer,
OIDCSubject: subject,
Email: email,
}, nil
}
if serr, ok := errors.AsType[*sqlite.Error](err); ok {
switch serr.Code() {
case 1555: // SQLITE_CONSTRAINT_PRIMARYKEY: handle taken, try next suffix
continue
case 2067: // SQLITE_CONSTRAINT_UNIQUE: the tenants_identity index tripped
return Tenant{}, fmt.Errorf("identity %q already bound to a tenant", subject)
}
}
return Tenant{}, fmt.Errorf("insert tenant: %w", err)
}
}
// handleFromEmail derives a tenant handle from an email's local part. Handles
// appear in SSH connect names <tenant>.<vm>, so they must be dot-free and
// stable; anything outside [a-z0-9-] flattens to '-'.
func handleFromEmail(email string) string {
local := email
if i := strings.IndexByte(email, '@'); i >= 0 {
local = email[:i]
}
var b strings.Builder
for _, r := range strings.ToLower(local) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
b.WriteRune(r)
} else {
b.WriteByte('-')
}
}
h := strings.Trim(b.String(), "-")
if h == "" {
return "user"
}
return h
}