This commit is contained in:
2026-08-14 00:06:43 +02:00
parent 841a05572e
commit a9395585f8
22 changed files with 326 additions and 22 deletions
+1
View File
@@ -7,6 +7,7 @@ var (
ErrPasswordTooShort = errors.New("password must be at least 8 characters")
ErrUserNotFound = errors.New("user not found")
ErrNotCompanyMember = errors.New("not a member of company")
ErrCompanyNotFound = errors.New("company not found")
ErrInviteNotFound = errors.New("invite not found")
ErrEmailRequired = errors.New("email is required")
ErrSyntheticEmail = errors.New("synthetic migration email cannot receive invites")
+54
View File
@@ -469,7 +469,61 @@ func (s *Service) ListUserCompanies(ctx context.Context, userID uuid.UUID) ([]Co
return out, rows.Err()
}
// ListCompanies returns tenants for platform staff company switching (id + name).
// Ordered with A1 / demo sandboxes first, then name. Caps at limit (default 500).
func (s *Service) ListCompanies(ctx context.Context, limit int) ([]Company, error) {
if s == nil || s.Pool == nil {
return nil, errors.New("auth service unavailable")
}
if limit <= 0 || limit > 2000 {
limit = 500
}
const a1LegacyCompanyID = "97e1a309-3d23-4aa2-b518-8e8d7afdfec7"
rows, err := s.Pool.Query(ctx, `
SELECT c.id, c.name
FROM companies c
ORDER BY
CASE
WHEN lower(c.name) = 'a1 slovenija' THEN 0
WHEN lower(c.name) = 'local demo co' THEN 0
WHEN lower(COALESCE(c.legacy_company_id, '')) = lower($1) THEN 0
WHEN lower(c.name) IN ('platform demo', 'demo') THEN 1
ELSE 2
END,
c.name
LIMIT $2`, a1LegacyCompanyID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]Company, 0)
for rows.Next() {
var c Company
if err := rows.Scan(&c.ID, &c.Name); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// CompanyByID loads a single company or ErrNotCompanyMember-style miss via pgx.ErrNoRows.
func (s *Service) CompanyByID(ctx context.Context, companyID uuid.UUID) (Company, error) {
if s == nil || s.Pool == nil {
return Company{}, errors.New("auth service unavailable")
}
var c Company
err := s.Pool.QueryRow(ctx, `SELECT id, name FROM companies WHERE id = $1`, companyID).Scan(&c.ID, &c.Name)
if errors.Is(err, pgx.ErrNoRows) {
return Company{}, ErrCompanyNotFound
}
return c, err
}
func (s *Service) EnsureMembership(ctx context.Context, userID, companyID uuid.UUID) (Membership, error) {
if s == nil || s.Pool == nil {
return Membership{}, ErrNotCompanyMember
}
var m Membership
err := s.Pool.QueryRow(ctx, `
SELECT id, company_id, user_id, role, status
+5 -4
View File
@@ -26,8 +26,9 @@ func NewSessionManager(pool *pgxpool.Pool, cookieName string, secure bool, idleH
}
const (
SessionUserIDKey = "user_id"
SessionCompanyIDKey = "company_id"
SessionImpersonatorIDKey = "impersonator_id" // non-prod user switch: original admin/demo
SessionVersionKey = "session_version" // must match users.session_version
SessionUserIDKey = "user_id"
SessionCompanyIDKey = "company_id"
SessionImpersonatorIDKey = "impersonator_id" // non-prod user switch: original admin/demo
SessionStaffHomeCompanyKey = "staff_home_company_id" // platform admin tenant switch: home company to revert to
SessionVersionKey = "session_version" // must match users.session_version
)
+81 -7
View File
@@ -140,8 +140,9 @@ func (s *Server) beginAuthenticatedSession(ctx context.Context, userID, companyI
}
s.Sessions.Put(ctx, auth.SessionUserIDKey, userID.String())
s.putSessionVersion(ctx, userID)
// Fresh login/register clears any prior impersonation chain.
// Fresh login/register clears any prior impersonation chain / staff tenant act-as.
s.Sessions.Remove(ctx, auth.SessionImpersonatorIDKey)
s.Sessions.Remove(ctx, auth.SessionStaffHomeCompanyKey)
if companyID == uuid.Nil {
s.Sessions.Put(ctx, auth.SessionCompanyIDKey, "")
return nil
@@ -302,16 +303,32 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusInternalServerError, "failed to load companies")
return
}
staffTenantSwitch := false
access, accessErr := s.Auth.GetStaffAccess(r.Context(), uid)
if accessErr == nil && access.FullAdmin && access.Role == auth.StaffRoleAdmin {
staffTenantSwitch = true
all, listErr := s.Auth.ListCompanies(r.Context(), 500)
if listErr != nil {
Error(w, http.StatusInternalServerError, "failed to load companies")
return
}
companies = all
}
cidStr := s.Sessions.GetString(r.Context(), auth.SessionCompanyIDKey)
homeStr := strings.TrimSpace(s.Sessions.GetString(r.Context(), auth.SessionStaffHomeCompanyKey))
out := map[string]any{
"user": user,
"companies": companies,
"active_company_id": cidStr,
}
if access, err := s.Auth.GetStaffAccess(r.Context(), uid); err == nil && (access.FullAdmin || access.SupportDesk) {
if accessErr == nil && (access.FullAdmin || access.SupportDesk) {
out["staff_access"] = access
out["staff_capabilities"] = auth.StaffCapabilities(access.Role)
}
if staffTenantSwitch {
out["staff_tenant_switch"] = true
}
staffOverride := false
if cid, err := uuid.Parse(cidStr); err == nil {
for _, c := range companies {
if c.ID == cid {
@@ -319,13 +336,33 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
break
}
}
// Staff may have selected a company not in the capped list — resolve by id.
if _, ok := out["company"]; !ok && staffTenantSwitch {
if c, cerr := s.Auth.CompanyByID(r.Context(), cid); cerr == nil {
out["company"] = c
}
}
if credits, err := s.Billing.CreditsOverview(r.Context(), cid, s.Config.LowCreditsThreshold); err == nil {
out["credits"] = credits
}
if m, err := s.Auth.EnsureMembership(r.Context(), uid, cid); err == nil {
out["membership"] = map[string]string{"role": m.Role, "status": m.Status}
} else if staffTenantSwitch && errors.Is(err, auth.ErrNotCompanyMember) {
staffOverride = true
out["membership"] = map[string]any{"role": "admin", "status": "active", "staff_override": true}
}
}
if staffTenantSwitch && homeStr != "" {
out["staff_home_company_id"] = homeStr
if hid, err := uuid.Parse(homeStr); err == nil {
if c, cerr := s.Auth.CompanyByID(r.Context(), hid); cerr == nil {
out["staff_home_company"] = c
}
}
}
if staffOverride {
out["staff_tenant_acting"] = true
}
impersonating := false
if impStr := strings.TrimSpace(s.Sessions.GetString(r.Context(), auth.SessionImpersonatorIDKey)); impStr != "" {
if impID, err := uuid.Parse(impStr); err == nil && impID != uuid.Nil {
@@ -345,8 +382,7 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
if !s.Config.IsProduction() {
canSwitch := impersonating
if !canSwitch {
access, err := s.checkStaffAccess(r.Context(), uid)
if err == nil && access.FullAdmin {
if accessErr == nil && access.FullAdmin {
canSwitch = true
} else if isLocalDemoEmail(user.Email) {
canSwitch = true
@@ -418,10 +454,48 @@ func (s *Server) handleSelectCompany(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusBadRequest, "invalid company_id")
return
}
if _, err := s.Auth.EnsureMembership(r.Context(), uid, cid); err != nil {
Error(w, http.StatusForbidden, "forbidden")
return
_, memErr := s.Auth.EnsureMembership(r.Context(), uid, cid)
staffOK := s.staffMayActAsCompany(r.Context(), uid)
if memErr != nil {
if !(errors.Is(memErr, auth.ErrNotCompanyMember) && staffOK) {
Error(w, http.StatusForbidden, "forbidden")
return
}
if _, cerr := s.Auth.CompanyByID(r.Context(), cid); cerr != nil {
if errors.Is(cerr, auth.ErrCompanyNotFound) {
Error(w, http.StatusNotFound, "company not found")
return
}
Error(w, http.StatusInternalServerError, "company lookup failed")
return
}
// First hop into a foreign tenant: remember home company for revert.
if strings.TrimSpace(s.Sessions.GetString(r.Context(), auth.SessionStaffHomeCompanyKey)) == "" {
if home := s.resolveStaffHomeCompany(r.Context(), uid); home != uuid.Nil {
s.Sessions.Put(r.Context(), auth.SessionStaffHomeCompanyKey, home.String())
}
}
} else {
// Back on a membership company — clear act-as home.
s.Sessions.Remove(r.Context(), auth.SessionStaffHomeCompanyKey)
}
s.Sessions.Put(r.Context(), auth.SessionCompanyIDKey, cid.String())
JSON(w, http.StatusOK, map[string]string{"company_id": cid.String()})
}
// resolveStaffHomeCompany picks the company to restore on revert: current session
// company when the admin is a member, else their first membership company.
func (s *Server) resolveStaffHomeCompany(ctx context.Context, userID uuid.UUID) uuid.UUID {
if cur := strings.TrimSpace(s.Sessions.GetString(ctx, auth.SessionCompanyIDKey)); cur != "" {
if cid, err := uuid.Parse(cur); err == nil {
if _, err := s.Auth.EnsureMembership(ctx, userID, cid); err == nil {
return cid
}
}
}
companies, err := s.Auth.ListUserCompanies(ctx, userID)
if err != nil || len(companies) == 0 {
return uuid.Nil
}
return companies[0].ID
}
+16
View File
@@ -192,6 +192,12 @@ func (s *Server) RequireCompany(next http.Handler) http.Handler {
}
m, err := s.Auth.EnsureMembership(r.Context(), uid, cid)
if err != nil {
if errors.Is(err, auth.ErrNotCompanyMember) && s.staffMayActAsCompany(r.Context(), uid) {
ctx := context.WithValue(r.Context(), ctxCompanyID, cid)
ctx = context.WithValue(ctx, ctxRole, "admin")
next.ServeHTTP(w, r.WithContext(ctx))
return
}
Error(w, http.StatusForbidden, "forbidden")
return
}
@@ -201,6 +207,16 @@ func (s *Server) RequireCompany(next http.Handler) http.Handler {
})
}
// staffMayActAsCompany is true only for platform staff_role=admin (or legacy
// is_platform_admin → admin). Developers and support_staff cannot tenant-switch.
func (s *Server) staffMayActAsCompany(ctx context.Context, userID uuid.UUID) bool {
access, err := s.checkStaffAccess(ctx, userID)
if err != nil || !access.FullAdmin {
return false
}
return access.Role == auth.StaffRoleAdmin
}
func (s *Server) CSRF(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Public API-key and token export routes do not use cookie CSRF.
+99
View File
@@ -150,6 +150,105 @@ func TestRequireSessionRejectsStaleSessionVersion(t *testing.T) {
}
}
func TestRequireCompanyAllowsFullStaffWithoutMembership(t *testing.T) {
t.Parallel()
sm := scs.New()
uid := uuid.New()
cid := uuid.New()
s := &Server{
Sessions: sm,
Config: config.Config{},
Auth: &auth.Service{},
testStaffAccess: func(ctx context.Context, userID uuid.UUID) (auth.StaffAccess, error) {
if userID != uid {
t.Fatalf("unexpected user %s", userID)
}
return auth.ResolveStaffAccess(true, auth.StaffRoleAdmin), nil
},
}
var capturedToken string
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sm.Put(r.Context(), auth.SessionUserIDKey, uid.String())
sm.Put(r.Context(), auth.SessionCompanyIDKey, cid.String())
w.WriteHeader(http.StatusNoContent)
}))
seedRec := httptest.NewRecorder()
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
for _, c := range seedRec.Result().Cookies() {
if c.Name == sm.Cookie.Name {
capturedToken = c.Value
}
}
if capturedToken == "" {
t.Fatal("expected session cookie")
}
var gotCompany uuid.UUID
var gotRole string
h := LoadSession(sm)(s.RequireSession(s.RequireCompany(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotCompany, _ = CompanyIDFromContext(r.Context())
gotRole, _ = RoleFromContext(r.Context())
w.WriteHeader(http.StatusNoContent)
}))))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/company", nil)
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: capturedToken})
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204 staff override", rec.Code)
}
if gotCompany != cid {
t.Fatalf("company = %s, want %s", gotCompany, cid)
}
if gotRole != "admin" {
t.Fatalf("role = %q, want admin", gotRole)
}
}
func TestRequireCompanyDeniesDeveloperStaffWithoutMembership(t *testing.T) {
t.Parallel()
sm := scs.New()
uid := uuid.New()
cid := uuid.New()
s := &Server{
Sessions: sm,
Config: config.Config{},
Auth: &auth.Service{},
testStaffAccess: func(ctx context.Context, userID uuid.UUID) (auth.StaffAccess, error) {
return auth.ResolveStaffAccess(true, auth.StaffRoleDeveloper), nil
},
}
var capturedToken string
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sm.Put(r.Context(), auth.SessionUserIDKey, uid.String())
sm.Put(r.Context(), auth.SessionCompanyIDKey, cid.String())
w.WriteHeader(http.StatusNoContent)
}))
seedRec := httptest.NewRecorder()
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
for _, c := range seedRec.Result().Cookies() {
if c.Name == sm.Cookie.Name {
capturedToken = c.Value
}
}
if capturedToken == "" {
t.Fatal("expected session cookie")
}
h := LoadSession(sm)(s.RequireSession(s.RequireCompany(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/company", nil)
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: capturedToken})
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 for developer without membership", rec.Code)
}
}
func TestRequireCompanyRequiresSelection(t *testing.T) {
t.Parallel()
sm := scs.New()