a73x

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
}