1. Tenant isolation bypass (CRITICAL): TenantResolver now verifies user has access to X-Tenant-ID via user_tenants lookup before setting context. Added VerifyAccess method to TenantLookup interface and TenantService. 2. Consolidated tenant resolution: Removed duplicate resolveTenant() from helpers.go and tenant resolution from auth middleware. TenantResolver is now the single source of truth. Deadlines and AI handlers use auth.TenantFromContext() instead of direct DB queries. 3. CalDAV credential masking: tenant settings responses now mask CalDAV passwords with "********" via maskSettingsPassword helper. Applied to GetTenant, ListTenants, and UpdateSettings responses. 4. CORS + security headers: New middleware/security.go with CORS (restricted to FRONTEND_ORIGIN) and security headers (X-Frame-Options, X-Content-Type-Options, HSTS, Referrer-Policy, X-XSS-Protection). 5. Internal error leaking: All writeError(w, 500, err.Error()) replaced with internalError() that logs via slog and returns generic "internal error" to client. Same for jsonError in tenant handler. 6. Input validation: Max length on title (500), description (10000), case_number (100), search (200). Pagination clamped to max 100. Content-Disposition filename sanitized against header injection. Regression test added for tenant access denial (403 on unauthorized X-Tenant-ID). All existing tests pass, go vet clean.
241 lines
6.8 KiB
Go
241 lines
6.8 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
|
|
)
|
|
|
|
type TenantService struct {
|
|
db *sqlx.DB
|
|
}
|
|
|
|
func NewTenantService(db *sqlx.DB) *TenantService {
|
|
return &TenantService{db: db}
|
|
}
|
|
|
|
// Create creates a new tenant and assigns the creator as owner.
|
|
func (s *TenantService) Create(ctx context.Context, userID uuid.UUID, name, slug string) (*models.Tenant, error) {
|
|
tx, err := s.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin transaction: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
var tenant models.Tenant
|
|
err = tx.QueryRowxContext(ctx,
|
|
`INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING id, name, slug, settings, created_at, updated_at`,
|
|
name, slug,
|
|
).StructScan(&tenant)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("insert tenant: %w", err)
|
|
}
|
|
|
|
_, err = tx.ExecContext(ctx,
|
|
`INSERT INTO user_tenants (user_id, tenant_id, role) VALUES ($1, $2, 'owner')`,
|
|
userID, tenant.ID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("assign owner: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("commit: %w", err)
|
|
}
|
|
|
|
return &tenant, nil
|
|
}
|
|
|
|
// ListForUser returns all tenants the user belongs to.
|
|
func (s *TenantService) ListForUser(ctx context.Context, userID uuid.UUID) ([]models.TenantWithRole, error) {
|
|
var tenants []models.TenantWithRole
|
|
err := s.db.SelectContext(ctx, &tenants,
|
|
`SELECT t.id, t.name, t.slug, t.settings, t.created_at, t.updated_at, ut.role
|
|
FROM tenants t
|
|
JOIN user_tenants ut ON ut.tenant_id = t.id
|
|
WHERE ut.user_id = $1
|
|
ORDER BY t.name`,
|
|
userID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list tenants: %w", err)
|
|
}
|
|
return tenants, nil
|
|
}
|
|
|
|
// GetByID returns a single tenant. The caller must verify the user has access.
|
|
func (s *TenantService) GetByID(ctx context.Context, tenantID uuid.UUID) (*models.Tenant, error) {
|
|
var tenant models.Tenant
|
|
err := s.db.GetContext(ctx, &tenant,
|
|
`SELECT id, name, slug, settings, created_at, updated_at FROM tenants WHERE id = $1`,
|
|
tenantID,
|
|
)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get tenant: %w", err)
|
|
}
|
|
return &tenant, nil
|
|
}
|
|
|
|
// GetUserRole returns the user's role in a tenant, or empty string if not a member.
|
|
func (s *TenantService) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) {
|
|
var role string
|
|
err := s.db.GetContext(ctx, &role,
|
|
`SELECT role FROM user_tenants WHERE user_id = $1 AND tenant_id = $2`,
|
|
userID, tenantID,
|
|
)
|
|
if err == sql.ErrNoRows {
|
|
return "", nil
|
|
}
|
|
if err != nil {
|
|
return "", fmt.Errorf("get user role: %w", err)
|
|
}
|
|
return role, nil
|
|
}
|
|
|
|
// VerifyAccess checks if a user has access to a given tenant.
|
|
func (s *TenantService) VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error) {
|
|
var exists bool
|
|
err := s.db.GetContext(ctx, &exists,
|
|
`SELECT EXISTS(SELECT 1 FROM user_tenants WHERE user_id = $1 AND tenant_id = $2)`,
|
|
userID, tenantID,
|
|
)
|
|
if err != nil {
|
|
return false, fmt.Errorf("verify tenant access: %w", err)
|
|
}
|
|
return exists, nil
|
|
}
|
|
|
|
// FirstTenantForUser returns the user's first tenant (by name), used as default.
|
|
func (s *TenantService) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) {
|
|
var tenantID uuid.UUID
|
|
err := s.db.GetContext(ctx, &tenantID,
|
|
`SELECT t.id FROM tenants t
|
|
JOIN user_tenants ut ON ut.tenant_id = t.id
|
|
WHERE ut.user_id = $1
|
|
ORDER BY t.name LIMIT 1`,
|
|
userID,
|
|
)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("first tenant: %w", err)
|
|
}
|
|
return &tenantID, nil
|
|
}
|
|
|
|
// ListMembers returns all members of a tenant.
|
|
func (s *TenantService) ListMembers(ctx context.Context, tenantID uuid.UUID) ([]models.UserTenant, error) {
|
|
var members []models.UserTenant
|
|
err := s.db.SelectContext(ctx, &members,
|
|
`SELECT user_id, tenant_id, role, created_at FROM user_tenants WHERE tenant_id = $1 ORDER BY created_at`,
|
|
tenantID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list members: %w", err)
|
|
}
|
|
return members, nil
|
|
}
|
|
|
|
// InviteByEmail looks up a user by email in auth.users and adds them to the tenant.
|
|
func (s *TenantService) InviteByEmail(ctx context.Context, tenantID uuid.UUID, email, role string) (*models.UserTenant, error) {
|
|
// Look up user in Supabase auth.users
|
|
var userID uuid.UUID
|
|
err := s.db.GetContext(ctx, &userID,
|
|
`SELECT id FROM auth.users WHERE email = $1`,
|
|
email,
|
|
)
|
|
if err == sql.ErrNoRows {
|
|
return nil, fmt.Errorf("no user found with email %s", email)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("lookup user: %w", err)
|
|
}
|
|
|
|
// Check if already a member
|
|
var exists bool
|
|
err = s.db.GetContext(ctx, &exists,
|
|
`SELECT EXISTS(SELECT 1 FROM user_tenants WHERE user_id = $1 AND tenant_id = $2)`,
|
|
userID, tenantID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("check membership: %w", err)
|
|
}
|
|
if exists {
|
|
return nil, fmt.Errorf("user is already a member of this tenant")
|
|
}
|
|
|
|
var ut models.UserTenant
|
|
err = s.db.QueryRowxContext(ctx,
|
|
`INSERT INTO user_tenants (user_id, tenant_id, role) VALUES ($1, $2, $3)
|
|
RETURNING user_id, tenant_id, role, created_at`,
|
|
userID, tenantID, role,
|
|
).StructScan(&ut)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invite user: %w", err)
|
|
}
|
|
|
|
return &ut, nil
|
|
}
|
|
|
|
// UpdateSettings merges new settings into the tenant's existing settings JSONB.
|
|
func (s *TenantService) UpdateSettings(ctx context.Context, tenantID uuid.UUID, settings json.RawMessage) (*models.Tenant, error) {
|
|
var tenant models.Tenant
|
|
err := s.db.QueryRowxContext(ctx,
|
|
`UPDATE tenants SET settings = COALESCE(settings, '{}'::jsonb) || $1::jsonb, updated_at = NOW()
|
|
WHERE id = $2
|
|
RETURNING id, name, slug, settings, created_at, updated_at`,
|
|
settings, tenantID,
|
|
).StructScan(&tenant)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("update settings: %w", err)
|
|
}
|
|
return &tenant, nil
|
|
}
|
|
|
|
// RemoveMember removes a user from a tenant. Cannot remove the last owner.
|
|
func (s *TenantService) RemoveMember(ctx context.Context, tenantID, userID uuid.UUID) error {
|
|
// Check if the user being removed is an owner
|
|
role, err := s.GetUserRole(ctx, userID, tenantID)
|
|
if err != nil {
|
|
return fmt.Errorf("check role: %w", err)
|
|
}
|
|
if role == "" {
|
|
return fmt.Errorf("user is not a member of this tenant")
|
|
}
|
|
|
|
if role == "owner" {
|
|
// Count owners — prevent removing the last one
|
|
var ownerCount int
|
|
err := s.db.GetContext(ctx, &ownerCount,
|
|
`SELECT COUNT(*) FROM user_tenants WHERE tenant_id = $1 AND role = 'owner'`,
|
|
tenantID,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("count owners: %w", err)
|
|
}
|
|
if ownerCount <= 1 {
|
|
return fmt.Errorf("cannot remove the last owner of a tenant")
|
|
}
|
|
}
|
|
|
|
_, err = s.db.ExecContext(ctx,
|
|
`DELETE FROM user_tenants WHERE user_id = $1 AND tenant_id = $2`,
|
|
userID, tenantID,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("remove member: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|