diff --git a/console/src/api.ts b/console/src/api.ts index 30815d031..3d621a53c 100644 --- a/console/src/api.ts +++ b/console/src/api.ts @@ -6,6 +6,7 @@ import type { ActionCreateParams, ActionUpdateParams, Admin, + AdminOrganization, AuthDriver, Campaign, CampaignCreateParams, @@ -242,6 +243,19 @@ const api = { whoami: async () => await client.get("/admin/tenant/whoami").then((r) => r.data), }, + // adminOrganizations are the organizations the logged-in admin belongs to + // (distinct from `organizations`, which is end-user CRM data). + adminOrganizations: { + mine: async () => + await client + .get>("/admin/organizations") + .then((r) => r.data), + setActive: async (organizationId: UUID) => + await client.post("/admin/active-organization", { + organization_id: organizationId, + }), + }, + projects: { ...createEntityPath("/admin/projects"), all: async () => diff --git a/console/src/components/app-sidebar.tsx b/console/src/components/app-sidebar.tsx index 6f4041e4f..a589108d9 100644 --- a/console/src/components/app-sidebar.tsx +++ b/console/src/components/app-sidebar.tsx @@ -2,6 +2,7 @@ import * as React from "react" import { useTranslation } from "react-i18next" import { ProjectSwitcher } from "@/components/project-switcher" +import { OrganizationSwitcher } from "@/components/organization-switcher" import { Sidebar, SidebarContent, @@ -51,9 +52,21 @@ export function AppSidebar({ }, []), ) + const [organizations] = useResolver( + React.useCallback(async () => { + try { + return (await api.adminOrganizations.mine()).results + } catch (error) { + console.error("Failed to fetch organizations:", error) + return [] + } + }, []), + ) + return ( + {organizations && } {allProjects && project && ( )} diff --git a/console/src/components/organization-switcher.tsx b/console/src/components/organization-switcher.tsx new file mode 100644 index 000000000..b57676d6a --- /dev/null +++ b/console/src/components/organization-switcher.tsx @@ -0,0 +1,94 @@ +import { useState } from "react" +import { Check, ChevronsUpDown, Building2, Loader2 } from "lucide-react" +import { toast } from "sonner" +import { useTranslation } from "react-i18next" + +import type { AdminOrganization } from "@/types" +import api from "@/api" + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar" + +export function OrganizationSwitcher({ organizations }: { organizations: AdminOrganization[] }) { + const { t } = useTranslation() + const [switching, setSwitching] = useState(false) + + // Nothing to switch between — hide the control entirely. + if (organizations.length < 2) { + return null + } + + // is_active is set by the API from the RESOLVED active organization, so + // exactly one entry is normally flagged. The organizations[0] fallback only + // guards the unexpected case where none is (e.g. a stale read); the + // length < 2 check above guarantees organizations[0] exists here. + const current = organizations.find((o) => o.is_active) ?? organizations[0] + + const handleSelect = async (organization: AdminOrganization) => { + if (organization.is_active || switching) { + return + } + setSwitching(true) + try { + await api.adminOrganizations.setActive(organization.id) + // The active organization scopes almost everything server-side, so a + // full reload is the simplest way to land in a clean, consistent state. + window.location.href = "/" + } catch { + toast.error(t("org_switch_failed", "Failed to switch organization.")) + setSwitching(false) + } + } + + return ( + + + + + +
+ {switching ? ( + + ) : ( + + )} +
+
+ + {t("organization", "Organization")} + + {current.name} +
+ +
+
+ + {organizations.map((organization) => ( + handleSelect(organization)} + className="cursor-pointer" + > + {organization.name} + {organization.is_active && } + + ))} + +
+
+
+ ) +} diff --git a/console/src/oapi/management.generated.ts b/console/src/oapi/management.generated.ts index a3fe198b3..087cc241e 100644 --- a/console/src/oapi/management.generated.ts +++ b/console/src/oapi/management.generated.ts @@ -660,6 +660,46 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/organizations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List my organizations + * @description Lists the organizations the authenticated admin is a member of, with the active one flagged + */ + get: operations["ListMyOrganizations"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/active-organization": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Set active organization + * @description Switches the authenticated admin's active organization, which scopes subsequent requests + */ + post: operations["SetActiveOrganization"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/tenant/admins": { parameters: { query?: never; @@ -5102,6 +5142,32 @@ export interface components { channel?: string; }; }; + AdminOrganization: { + /** + * Format: uuid + * @example 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d + */ + id: string; + /** @example Acme Inc */ + name: string; + /** + * @description The admin's role within this organization + * @example owner + */ + role: string; + /** + * @description Whether this is the admin's currently active organization + * @example true + */ + is_active: boolean; + }; + SetActiveOrganization: { + /** + * Format: uuid + * @example 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d + */ + organization_id: string; + }; CreateProjectInvite: { /** * Format: email @@ -6682,6 +6748,52 @@ export interface operations { default: components["responses"]["Error"]; }; }; + ListMyOrganizations: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Organizations retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + results: components["schemas"]["AdminOrganization"][]; + }; + }; + }; + default: components["responses"]["Error"]; + }; + }; + SetActiveOrganization: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetActiveOrganization"]; + }; + }; + responses: { + /** @description Active organization updated successfully */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Error"]; + }; + }; listAdmins: { parameters: { query?: { diff --git a/console/src/types.ts b/console/src/types.ts index 025c496f0..074ee857d 100644 --- a/console/src/types.ts +++ b/console/src/types.ts @@ -333,6 +333,13 @@ export interface Admin { export const projectRoles = ["support", "client", "editor", "admin"] as const +export interface AdminOrganization { + id: UUID + name: string + role: string + is_active: boolean +} + export interface ProjectInvite { id: UUID project_id: UUID diff --git a/internal/http/auth/auth.go b/internal/http/auth/auth.go index 28066e799..8fcb8c0ce 100644 --- a/internal/http/auth/auth.go +++ b/internal/http/auth/auth.go @@ -11,6 +11,7 @@ import ( "github.com/getkin/kin-openapi/openapi3filter" "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" "github.com/lunogram/platform/internal/config" "github.com/lunogram/platform/internal/rbac" "github.com/lunogram/platform/internal/store/management" @@ -110,16 +111,80 @@ func WithJWT(config config.Auth, mgmt *management.State) Handler { return ctx, ErrUnauthorized } + orgID, err := resolveActiveOrganization(ctx, mgmt, admin) + if err != nil { + // A real failure resolving the active organization (e.g. a transient + // DB error) must NOT fail open onto the home org — that would bypass + // the revoked-membership check on a hot security path. Surface the + // error so the request fails instead of silently granting scope. + return ctx, err + } + actor := rbac.NewActor( rbac.ActorAdmin, admin.ID.String(), - rbac.WithOrganizationID(admin.OrganizationID), + rbac.WithOrganizationID(orgID), ) return rbac.WithActor(ctx, actor), nil } } +// resolveActiveOrganization determines which organization scopes the request. +// An admin may belong to several organizations; the session is scoped to their +// active organization. The stored active organization is validated against +// current membership on every request so that revoking a membership (or a stale +// active_organization_id) cannot leak access to an organization the admin no +// longer belongs to. It falls back to the home organization, then to any +// remaining membership. +// +// This runs on every authenticated request and gates the revoked-membership +// check, so a DB error must be propagated, not swallowed. Swallowing it would +// fail OPEN — defaulting to the home org and bypassing the membership check on +// a transient failure. Only a clean "not a member" result (no error) advances +// to the next fallback. +func resolveActiveOrganization(ctx context.Context, mgmt *management.State, admin *management.Admin) (uuid.UUID, error) { + active := admin.OrganizationID + if admin.ActiveOrganizationID != nil { + active = *admin.ActiveOrganizationID + } + + ok, err := mgmt.IsMember(ctx, active, admin.ID) + if err != nil { + return uuid.Nil, err + } + if ok { + return active, nil + } + + // The active org is stale (membership revoked). Try the home org, but only + // if it differs from the active org we already checked. + if admin.OrganizationID != active { + ok, err := mgmt.IsMember(ctx, admin.OrganizationID, admin.ID) + if err != nil { + return uuid.Nil, err + } + if ok { + return admin.OrganizationID, nil + } + } + + // Neither the active nor home org is a current membership; fall back to any + // remaining membership so the admin can still reach an org they belong to. + orgs, err := mgmt.ListOrganizationsForAdmin(ctx, admin.ID) + if err != nil { + return uuid.Nil, err + } + if len(orgs) > 0 { + return orgs[0].ID, nil + } + + // The admin belongs to no organization. Scope to the home org as a last + // resort; org-scoped permission checks will deny access since there is no + // membership tuple, so this does not leak access. + return admin.OrganizationID, nil +} + func WithKey(mgmt *management.State) Handler { return func(ctx context.Context, tokenString string) (context.Context, error) { if tokenString == "" { diff --git a/internal/http/auth/auth_test.go b/internal/http/auth/auth_test.go new file mode 100644 index 000000000..0f3c46658 --- /dev/null +++ b/internal/http/auth/auth_test.go @@ -0,0 +1,82 @@ +package auth + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/lunogram/platform/internal/store/management" + teststore "github.com/lunogram/platform/internal/store/test" +) + +// TestResolveActiveOrganization exercises the active-organization resolution +// that scopes every authenticated request. The security-critical behaviour is +// that a stale (revoked) active organization falls back to a current membership +// and that a real DB error fails CLOSED rather than silently defaulting to the +// home org. +func TestResolveActiveOrganization(t *testing.T) { + t.Parallel() + + ctx := context.Background() + mgmtDB, _, _ := teststore.RunPostgreSQL(t) + mgmt := management.NewState(mgmtDB) + + homeOrg, err := mgmt.CreateOrganization(ctx, "Home Org") + require.NoError(t, err) + otherOrg, err := mgmt.CreateOrganization(ctx, "Other Org") + require.NoError(t, err) + + adminID, err := mgmt.CreateAdmin(ctx, management.Admin{ + OrganizationID: homeOrg, + Email: "switcher@example.com", + Role: "owner", + }) + require.NoError(t, err) + + // The admin is a member of both organizations. + require.NoError(t, mgmt.AddMember(ctx, homeOrg, adminID, "owner")) + require.NoError(t, mgmt.AddMember(ctx, otherOrg, adminID, "member")) + + t.Run("valid active membership is used", func(t *testing.T) { + require.NoError(t, mgmt.SetActiveOrganization(ctx, adminID, otherOrg)) + + admin, err := mgmt.GetAdmin(ctx, adminID) + require.NoError(t, err) + + got, err := resolveActiveOrganization(ctx, mgmt, admin) + require.NoError(t, err) + assert.Equal(t, otherOrg, got, "should scope to the active organization the admin still belongs to") + }) + + t.Run("stale active org falls back to home org", func(t *testing.T) { + require.NoError(t, mgmt.SetActiveOrganization(ctx, adminID, otherOrg)) + // Revoke the membership the active org points at. + require.NoError(t, mgmt.RemoveMember(ctx, otherOrg, adminID)) + + admin, err := mgmt.GetAdmin(ctx, adminID) + require.NoError(t, err) + + got, err := resolveActiveOrganization(ctx, mgmt, admin) + require.NoError(t, err) + assert.Equal(t, homeOrg, got, "a revoked active org must not leak access; fall back to a current membership") + + // restore for later subtests + require.NoError(t, mgmt.AddMember(ctx, otherOrg, adminID, "member")) + }) + + t.Run("DB error does not fail open", func(t *testing.T) { + admin, err := mgmt.GetAdmin(ctx, adminID) + require.NoError(t, err) + + // Force the membership query to fail by handing it a cancelled context. + // The resolver must propagate the error rather than silently default to + // the home org (which would bypass the revoked-membership check). + cancelled, cancel := context.WithCancel(ctx) + cancel() + + _, err = resolveActiveOrganization(cancelled, mgmt, admin) + require.Error(t, err, "a DB error must propagate, not silently default to the home org") + }) +} diff --git a/internal/http/auth/providers/basic.go b/internal/http/auth/providers/basic.go index b966031ef..7b8fb11be 100644 --- a/internal/http/auth/providers/basic.go +++ b/internal/http/auth/providers/basic.go @@ -4,12 +4,10 @@ import ( "context" "encoding/json" "errors" - "fmt" "net/http" "github.com/lunogram/platform/internal/config" "github.com/lunogram/platform/internal/http/auth" - "github.com/lunogram/platform/internal/rbac/access" "github.com/lunogram/platform/internal/store/management" ) @@ -102,15 +100,11 @@ func (p *BasicProvider) findOrCreateAdmin(ctx context.Context, email string) (*m return nil, err } - // Grant the new admin the owner role on the organization in the RBAC engine - // so that subsequent permission checks (e.g. read profile, list/create - // projects) succeed. - if p.rbac != nil { - for _, t := range access.OrganizationRoleTuples(admin.ID, admin.OrganizationID, admin.Role) { - if err := p.rbac.WriteTuple(ctx, t.User, t.Relation, t.Object); err != nil { - return nil, fmt.Errorf("failed to write RBAC tuple for new admin: %w", err) - } - } + // Record the home-organization membership and grant the owner role in the + // RBAC engine so that subsequent permission checks (e.g. read profile, + // list/create projects) succeed. + if err := provisionMembership(ctx, p.mgmt, p.rbac, admin.ID, admin.OrganizationID, admin.Role); err != nil { + return nil, err } return admin, nil diff --git a/internal/http/auth/providers/clerk.go b/internal/http/auth/providers/clerk.go index a89f36698..b05bfa8c3 100644 --- a/internal/http/auth/providers/clerk.go +++ b/internal/http/auth/providers/clerk.go @@ -5,7 +5,6 @@ import ( "database/sql" "encoding/json" "errors" - "fmt" "io" "net/http" @@ -14,7 +13,6 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/lunogram/platform/internal/config" "github.com/lunogram/platform/internal/http/auth" - "github.com/lunogram/platform/internal/rbac/access" "github.com/lunogram/platform/internal/store/management" svix "github.com/svix/svix-webhooks/go" "go.uber.org/zap" @@ -113,15 +111,11 @@ func (p *ClerkProvider) Authenticate(ctx context.Context, w http.ResponseWriter, return nil, err } - // Grant the new admin the owner role on the organization in the RBAC engine - // so that subsequent permission checks (e.g. read profile, list/create - // projects) succeed. - if p.rbac != nil { - for _, t := range access.OrganizationRoleTuples(admin.ID, admin.OrganizationID, admin.Role) { - if err := p.rbac.WriteTuple(ctx, t.User, t.Relation, t.Object); err != nil { - return nil, fmt.Errorf("failed to write RBAC tuple for new admin: %w", err) - } - } + // Record the home-organization membership and grant the owner role in the + // RBAC engine so that subsequent permission checks (e.g. read profile, + // list/create projects) succeed. + if err := provisionMembership(ctx, p.mgmt, p.rbac, admin.ID, admin.OrganizationID, admin.Role); err != nil { + return nil, err } return ctx, nil @@ -213,15 +207,10 @@ func (p *ClerkProvider) handleUserCreated(ctx context.Context, data json.RawMess return err } - // Grant the new admin the owner role on the organization in the RBAC engine - // so that subsequent permission checks (e.g. read profile, list/create - // projects) succeed. - if p.rbac != nil { - for _, t := range access.OrganizationRoleTuples(adminID, orgID, newAdmin.Role) { - if err := p.rbac.WriteTuple(ctx, t.User, t.Relation, t.Object); err != nil { - return fmt.Errorf("failed to write RBAC tuple for new admin: %w", err) - } - } + // Record the home-organization membership and grant the owner role in the + // RBAC engine so that subsequent permission checks succeed. + if err := provisionMembership(ctx, p.mgmt, p.rbac, adminID, orgID, newAdmin.Role); err != nil { + return err } return nil diff --git a/internal/http/auth/providers/provider.go b/internal/http/auth/providers/provider.go index 7877f127c..b41e5e7b6 100644 --- a/internal/http/auth/providers/provider.go +++ b/internal/http/auth/providers/provider.go @@ -3,11 +3,13 @@ package providers import ( "context" "errors" + "fmt" "net/http" "time" "github.com/google/uuid" "github.com/lunogram/platform/internal/config" + "github.com/lunogram/platform/internal/rbac/access" "github.com/lunogram/platform/internal/store/management" "go.uber.org/zap" ) @@ -40,6 +42,26 @@ type RBACWriter interface { WriteTuple(ctx context.Context, user, relation, object string) error } +// provisionMembership records a freshly provisioned admin's membership in its +// home organization: the organization_members row plus the matching RBAC owner +// tuples. Every admin-provisioning path calls this so the two representations +// stay in sync (an admin that is a member but has no tuples cannot pass +// permission checks, and vice versa). +func provisionMembership(ctx context.Context, mgmt *management.State, writer RBACWriter, adminID, organizationID uuid.UUID, role string) error { + if err := mgmt.AddMember(ctx, organizationID, adminID, role); err != nil { + return fmt.Errorf("failed to add organization membership for new admin: %w", err) + } + + if writer != nil { + for _, t := range access.OrganizationRoleTuples(adminID, organizationID, role) { + if err := writer.WriteTuple(ctx, t.User, t.Relation, t.Object); err != nil { + return fmt.Errorf("failed to write RBAC tuple for new admin: %w", err) + } + } + } + return nil +} + func NewProvider(cfg config.Auth, mgmt *management.State, logger *zap.Logger, rbac RBACWriter) (Provider, error) { switch cfg.Driver { case "basic": diff --git a/internal/http/controllers/v1/management/admins.go b/internal/http/controllers/v1/management/admins.go index bab9a9b30..04b58585a 100644 --- a/internal/http/controllers/v1/management/admins.go +++ b/internal/http/controllers/v1/management/admins.go @@ -1,6 +1,7 @@ package v1 import ( + "context" "database/sql" "errors" "net/http" @@ -11,6 +12,7 @@ import ( "github.com/lunogram/platform/internal/http/json" "github.com/lunogram/platform/internal/http/problem" "github.com/lunogram/platform/internal/rbac" + "github.com/lunogram/platform/internal/rbac/access" "github.com/lunogram/platform/internal/store" "github.com/lunogram/platform/internal/store/management" "go.uber.org/zap" @@ -230,49 +232,59 @@ func (srv *AdminsController) CreateAdmin(w http.ResponseWriter, r *http.Request) if existingAdmin != nil { logger = logger.With(zap.String("admin_id", existingAdmin.ID.String())) - logger.Info("updating existing admin") - - email := string(body.Email) - role := string(body.Role) - - update := management.AdminUpdate{ - Email: &email, - FirstName: body.FirstName, - LastName: body.LastName, - Role: &role, - } - - err = srv.store.UpdateAdmin(ctx, existingAdmin.ID, update) + logger.Info("adding existing admin to organization") + + // The email already belongs to a registered admin — possibly one whose + // home organization is a DIFFERENT org. We must NOT overwrite that admin's + // GLOBAL identity (email/name/role): the caller only has authority over + // their own organization, and rewriting another org's admin record would + // be a cross-org privilege escalation. Adding an existing person to an org + // is purely an organization-scoped membership grant; their global record + // is left untouched. The requested role becomes their membership role in + // THIS organization only. + _, err := provisionMembership(ctx, srv.db, srv.engine, actor.OrganizationID, string(body.Role), + func(ctx context.Context, _ *management.State) (uuid.UUID, error) { + return existingAdmin.ID, nil + }, + ) if err != nil { - logger.Error("failed to update admin", zap.Error(err)) - oapi.WriteProblem(w, err) + logger.Error("failed to provision organization membership", zap.Error(err)) + oapi.WriteProblem(w, problem.ErrInternal(problem.Describe("failed to add admin to organization"))) return } - updatedAdmin, err := srv.store.GetAdmin(ctx, existingAdmin.ID) + // Return the admin's current (unchanged) record. + admin, err := srv.store.GetAdmin(ctx, existingAdmin.ID) if err != nil { - logger.Error("failed to get updated admin", zap.Error(err)) + logger.Error("failed to get admin", zap.Error(err)) oapi.WriteProblem(w, err) return } - logger.Info("admin updated") - json.Write(w, http.StatusCreated, updatedAdmin.OAPI()) - return - } - - newAdmin := management.Admin{ - OrganizationID: actor.OrganizationID, - Email: string(body.Email), - FirstName: body.FirstName, - LastName: body.LastName, - Role: string(body.Role), - } - - adminID, err := srv.store.CreateAdmin(ctx, newAdmin) + logger.Info("existing admin added to organization") + json.Write(w, http.StatusCreated, admin.OAPI()) + return + } + + // Create the admin record and its organization membership atomically: the + // admin row is inserted inside the same transaction as the membership upsert, + // and the RBAC tuples are written only after the transaction commits. This + // prevents partial state (an admin with no membership, or a membership with + // no role tuples) on a mid-sequence failure. + adminID, err := provisionMembership(ctx, srv.db, srv.engine, actor.OrganizationID, string(body.Role), + func(ctx context.Context, tx *management.State) (uuid.UUID, error) { + return tx.CreateAdmin(ctx, management.Admin{ + OrganizationID: actor.OrganizationID, + Email: string(body.Email), + FirstName: body.FirstName, + LastName: body.LastName, + Role: string(body.Role), + }) + }, + ) if err != nil { logger.Error("failed to create admin", zap.Error(err)) - oapi.WriteProblem(w, err) + oapi.WriteProblem(w, problem.ErrInternal(problem.Describe("failed to create admin"))) return } @@ -287,6 +299,98 @@ func (srv *AdminsController) CreateAdmin(w http.ResponseWriter, r *http.Request) json.Write(w, http.StatusCreated, createdAdmin.OAPI()) } +// ListMyOrganizations returns the organizations the authenticated admin belongs +// to, flagging the one that currently scopes their session. +func (srv *AdminsController) ListMyOrganizations(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + actor := rbac.FromContext(ctx) + if actor == nil || actor.ID == "" { + oapi.WriteProblem(w, problem.ErrUnauthorized()) + return + } + + adminID, err := uuid.Parse(actor.ID) + if err != nil { + oapi.WriteProblem(w, problem.ErrForbidden(problem.Describe("only admins have organizations"))) + return + } + + orgs, err := srv.store.ListOrganizationsForAdmin(ctx, adminID) + if err != nil { + srv.logger.Error("failed to list organizations for admin", zap.Error(err)) + oapi.WriteProblem(w, err) + return + } + + results := make([]oapi.AdminOrganization, len(orgs)) + for i, o := range orgs { + results[i] = oapi.AdminOrganization{ + Id: o.ID, + Name: o.Name, + Role: o.Role, + // IsActive is derived from the RESOLVED active organization + // (actor.OrganizationID), not from the raw active_organization_id + // column. This is intentional: resolveActiveOrganization may have + // fallen back (e.g. the stored active org was revoked), so the actor's + // org is the org that actually scopes this request — which is exactly + // what the switcher should show as active. Do not "fix" this to read + // the stored column. + IsActive: o.ID == actor.OrganizationID, + } + } + + json.Write(w, http.StatusOK, struct { + Results []oapi.AdminOrganization `json:"results"` + }{Results: results}) +} + +// SetActiveOrganization switches the authenticated admin's active organization, +// which scopes subsequent requests. The admin must be a member of the target. +func (srv *AdminsController) SetActiveOrganization(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + actor := rbac.FromContext(ctx) + if actor == nil || actor.ID == "" { + oapi.WriteProblem(w, problem.ErrUnauthorized()) + return + } + + adminID, err := uuid.Parse(actor.ID) + if err != nil { + oapi.WriteProblem(w, problem.ErrForbidden(problem.Describe("only admins have organizations"))) + return + } + + var body oapi.SetActiveOrganizationJSONRequestBody + if err := json.Decode(r.Body, &body); err != nil { + oapi.WriteProblem(w, problem.ErrBadRequest(problem.Describe("invalid request body"))) + return + } + + // IDOR guard: an admin may only activate an organization they are a current + // member of. The membership check is the authorization boundary here, so a DB + // error must surface as a clean 500 — never as a 403 (which would let a + // transient failure masquerade as "not a member") and never leaking the raw + // error to the client. + isMember, err := srv.store.IsMember(ctx, body.OrganizationId, adminID) + if err != nil { + srv.logger.Error("failed to check organization membership", zap.Error(err)) + oapi.WriteProblem(w, problem.ErrInternal()) + return + } + if !isMember { + oapi.WriteProblem(w, problem.ErrForbidden(problem.Describe("you are not a member of this organization"))) + return + } + + if err := srv.store.SetActiveOrganization(ctx, adminID, body.OrganizationId); err != nil { + srv.logger.Error("failed to set active organization", zap.Error(err)) + oapi.WriteProblem(w, problem.ErrInternal()) + return + } + + w.WriteHeader(http.StatusNoContent) +} + func (srv *AdminsController) GetAdmin(w http.ResponseWriter, r *http.Request, adminID uuid.UUID) { ctx := r.Context() actor := rbac.FromContext(ctx) @@ -316,7 +420,13 @@ func (srv *AdminsController) GetAdmin(w http.ResponseWriter, r *http.Request, ad return } - if admin.OrganizationID != actor.OrganizationID { + inOrg, err := srv.store.IsMember(ctx, actor.OrganizationID, admin.ID) + if err != nil { + logger.Error("failed to check organization membership", zap.Error(err)) + oapi.WriteProblem(w, err) + return + } + if !inOrg { logger.Info("admin not in organization") oapi.WriteProblem(w, problem.ErrNotFound(problem.Describe("admin not found"))) return @@ -348,7 +458,13 @@ func (srv *AdminsController) UpdateAdmin(w http.ResponseWriter, r *http.Request, return } - if admin.OrganizationID != actor.OrganizationID { + inOrg, err := srv.store.IsMember(ctx, actor.OrganizationID, admin.ID) + if err != nil { + srv.logger.Error("failed to check organization membership", zap.Error(err)) + oapi.WriteProblem(w, err) + return + } + if !inOrg { srv.logger.Info("admin not in organization") oapi.WriteProblem(w, problem.ErrNotFound(problem.Describe("admin not found"))) return @@ -415,40 +531,68 @@ func (srv *AdminsController) DeleteAdmin(w http.ResponseWriter, r *http.Request, return } - admin, err := srv.store.GetAdmin(ctx, adminID) + member, err := srv.store.GetMember(ctx, actor.OrganizationID, adminID) if errors.Is(err, sql.ErrNoRows) { - srv.logger.Info("admin not found") + srv.logger.Info("admin not in organization") oapi.WriteProblem(w, problem.ErrNotFound(problem.Describe("admin not found"))) return } - if err != nil { - srv.logger.Error("failed to get admin", zap.Error(err)) + srv.logger.Error("failed to check organization membership", zap.Error(err)) oapi.WriteProblem(w, err) return } - if admin.OrganizationID != actor.OrganizationID { - srv.logger.Info("admin not in organization") - oapi.WriteProblem(w, problem.ErrNotFound(problem.Describe("admin not found"))) - return - } - logger := srv.logger.With( zap.String("organization_id", actor.OrganizationID.String()), zap.String("admin_id", adminID.String()), ) - logger.Info("deleting admin") + logger.Info("removing admin from organization") - err = srv.store.DeleteAdmin(ctx, adminID) + // Membership is the unit of removal now that an admin can belong to several + // organizations; the admin record is preserved so their other memberships + // keep working. The soft-delete and the home/active-org reconciliation run in + // one transaction so an admin can never be left pointing at an org they no + // longer belong to. Tuples are deleted only after the transaction commits. + tx, err := srv.db.BeginTxx(ctx, nil) if err != nil { - logger.Error("failed to delete admin", zap.Error(err)) + logger.Error("failed to begin transaction", zap.Error(err)) + oapi.WriteProblem(w, err) + return + } + defer tx.Rollback() //nolint:errcheck + + txStore := management.NewState(tx) + + if err := txStore.RemoveMember(ctx, actor.OrganizationID, adminID); err != nil { + logger.Error("failed to remove organization membership", zap.Error(err)) oapi.WriteProblem(w, err) return } - logger.Info("admin deleted") + // Clear a now-dangling active_organization_id and re-point the home org if it + // was the removed org, so correctness does not rely solely on the read-time + // fallback in resolveActiveOrganization. + if err := txStore.ReconcileAdminOrganizations(ctx, actor.OrganizationID, adminID); err != nil { + logger.Error("failed to reconcile admin organizations", zap.Error(err)) + oapi.WriteProblem(w, err) + return + } + + if err := tx.Commit(); err != nil { + logger.Error("failed to commit transaction", zap.Error(err)) + oapi.WriteProblem(w, err) + return + } + + if err := srv.engine.DeleteTuples(ctx, access.OrganizationRoleTuples(adminID, actor.OrganizationID, member.Role)); err != nil { + logger.Error("failed to delete RBAC tuples", zap.Error(err)) + oapi.WriteProblem(w, problem.ErrInternal(problem.Describe("failed to revoke organization role"))) + return + } + + logger.Info("admin removed from organization") w.WriteHeader(http.StatusNoContent) } diff --git a/internal/http/controllers/v1/management/admins_test.go b/internal/http/controllers/v1/management/admins_test.go index 09484b462..bf4bece5e 100644 --- a/internal/http/controllers/v1/management/admins_test.go +++ b/internal/http/controllers/v1/management/admins_test.go @@ -191,6 +191,139 @@ func TestGetProfileErrors(t *testing.T) { } } +// TestSetActiveOrganizationRejectsNonMember is the IDOR guard: an admin must +// not be able to activate an organization they are not a member of, even though +// they hold a valid session. +func TestSetActiveOrganizationRejectsNonMember(t *testing.T) { + t.Parallel() + + logger := zaptest.NewLogger(t) + ctx := t.Context() + mgmt, _, _ := teststore.RunPostgreSQL(t) + state := management.NewState(mgmt) + + homeOrg, err := state.CreateOrganization(ctx, "Home Org") + require.NoError(t, err) + foreignOrg, err := state.CreateOrganization(ctx, "Foreign Org") + require.NoError(t, err) + + adminID, err := state.CreateAdmin(ctx, management.Admin{ + OrganizationID: homeOrg, + Email: "idor@example.com", + Role: "owner", + }) + require.NoError(t, err) + require.NoError(t, state.AddMember(ctx, homeOrg, adminID, "owner")) + // Deliberately NOT a member of foreignOrg. + + actor := rbac.NewActor(rbac.ActorAdmin, adminID.String(), rbac.WithOrganizationID(homeOrg)) + engine, actorCtx := rbac.TestSetup(t, ctx, actor, "owner", "") + controller := NewAdminsController(logger, mgmt, engine) + + body := oapi.SetActiveOrganization{OrganizationId: foreignOrg} + bb, err := json.Marshal(body) + require.NoError(t, err) + + res := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/api/admin/active-organization", bytes.NewReader(bb)) + req = req.WithContext(actorCtx) + + controller.SetActiveOrganization(res, req) + + require.Equal(t, 403, res.Code, res.Body.String()) + + // The active organization must be unchanged after the rejected switch. + admin, err := state.GetAdmin(ctx, adminID) + require.NoError(t, err) + require.NotNil(t, admin.ActiveOrganizationID) + require.Equal(t, homeOrg, *admin.ActiveOrganizationID) +} + +// TestCreateAdminDoesNotMutateCrossOrgGlobalRecord verifies that adding an +// existing admin (whose home organization is a DIFFERENT org) to the caller's +// organization is purely a membership grant: it must NOT overwrite that admin's +// global email/name/role. Doing so would be a cross-organization privilege +// escalation. +func TestCreateAdminDoesNotMutateCrossOrgGlobalRecord(t *testing.T) { + t.Parallel() + + logger := zaptest.NewLogger(t) + ctx := t.Context() + mgmt, _, _ := teststore.RunPostgreSQL(t) + state := management.NewState(mgmt) + + orgA, err := state.CreateOrganization(ctx, "Org A") + require.NoError(t, err) + orgB, err := state.CreateOrganization(ctx, "Org B") + require.NoError(t, err) + + // The caller is an owner of Org A. + callerID, err := state.CreateAdmin(ctx, management.Admin{ + OrganizationID: orgA, + Email: "owner-a@example.com", + Role: "owner", + }) + require.NoError(t, err) + require.NoError(t, state.AddMember(ctx, orgA, callerID, "owner")) + + // The victim belongs to Org B with an "owner" global role and a known name. + victimFirst := "Victim" + victimLast := "Original" + victimID, err := state.CreateAdmin(ctx, management.Admin{ + OrganizationID: orgB, + Email: "victim@example.com", + FirstName: &victimFirst, + LastName: &victimLast, + Role: "owner", + }) + require.NoError(t, err) + require.NoError(t, state.AddMember(ctx, orgB, victimID, "owner")) + + actor := rbac.NewActor(rbac.ActorAdmin, callerID.String(), rbac.WithOrganizationID(orgA)) + engine, actorCtx := rbac.TestSetup(t, ctx, actor, "owner", "") + controller := NewAdminsController(logger, mgmt, engine) + + // Org A's owner adds the victim by email, requesting role "member" with an + // attempt to rewrite their name. None of these global fields may change. + attackFirst := "Hacked" + attackLast := "Name" + body := oapi.CreateAdmin{ + Email: "victim@example.com", + FirstName: &attackFirst, + LastName: &attackLast, + Role: oapi.OrganizationRoleMember, + } + bb, err := json.Marshal(body) + require.NoError(t, err) + + res := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/api/admin/admins", bytes.NewReader(bb)) + req = req.WithContext(actorCtx) + + controller.CreateAdmin(res, req) + require.Equal(t, 201, res.Code, res.Body.String()) + + // The victim's GLOBAL record is untouched. + victim, err := state.GetAdmin(ctx, victimID) + require.NoError(t, err) + require.Equal(t, orgB, victim.OrganizationID, "home org must not change") + require.Equal(t, "owner", victim.Role, "global role must not be downgraded") + require.NotNil(t, victim.FirstName) + require.Equal(t, "Victim", *victim.FirstName, "global first name must not be rewritten") + require.NotNil(t, victim.LastName) + require.Equal(t, "Original", *victim.LastName, "global last name must not be rewritten") + + // But they ARE now a member of Org A with the requested org-scoped role. + member, err := state.GetMember(ctx, orgA, victimID) + require.NoError(t, err) + require.Equal(t, "member", member.Role) + + // And Org B membership/role is preserved. + bMember, err := state.GetMember(ctx, orgB, victimID) + require.NoError(t, err) + require.Equal(t, "owner", bMember.Role) +} + func TestListProjectAdmins(t *testing.T) { t.Parallel() diff --git a/internal/http/controllers/v1/management/invites_enterprise.go b/internal/http/controllers/v1/management/invites_enterprise.go index 252a3633b..52deae8d5 100644 --- a/internal/http/controllers/v1/management/invites_enterprise.go +++ b/internal/http/controllers/v1/management/invites_enterprise.go @@ -184,32 +184,17 @@ func (srv *InviteController) CreateProjectInvite(w http.ResponseWriter, r *http. return } - project, err := srv.mgmt.GetProject(ctx, projectID, nil) - if err != nil { - logger.Error("failed to get project", zap.Error(err)) - oapi.WriteProblem(w, err) - return - } - - // Same-org / new-email guard. An invite may target a brand-new email (no - // admin account yet) or an existing admin that already belongs to the - // project's organization. Inviting an email that belongs to a *different* - // organization is rejected until admin↔organization many-to-many membership - // lands. When the invitee already has an account we denormalize its id so - // "my invites" can be matched even before the email column is touched. + // The invitee may be a brand-new email (no admin account yet) or an existing + // admin — possibly from another organization, who will be added as a member + // of this project's organization when they accept. When the invitee already + // has an account we denormalize its id so "my invites" can be matched even + // before they next sign in. var inviteeAdminID *uuid.UUID inviteeAdmin, err := srv.mgmt.GetAdminByEmail(ctx, inviteeEmail) switch { case err == nil: - if project.OrganizationID == nil || inviteeAdmin.OrganizationID != *project.OrganizationID { - // Use a generic message that does not confirm whether/where the email - // is registered, to limit account-enumeration across organizations. - // (The response still differs from the brand-new-email path; closing - // that gap fully needs a product decision — see PR notes.) - logger.Debug("invitee belongs to a different organization", zap.String("invitee_admin_id", inviteeAdmin.ID.String())) - oapi.WriteProblem(w, problem.ErrBadRequest(problem.Describe("this email cannot be invited to this project"))) - return - } + // The invitee may belong to a different organization; accepting the invite + // adds them as a member of this project's organization (see AcceptProjectInvite). inviteeAdminID = &inviteeAdmin.ID case errors.Is(err, sql.ErrNoRows): // Brand-new invitee — resolved by email when they sign up. @@ -378,9 +363,6 @@ func (srv *InviteController) AcceptProjectInvite(w http.ResponseWriter, r *http. } } - // TODO: once admin↔organization many-to-many membership lands, also add the - // admin to the organization that owns this project. - // Mark the invite accepted. The guarded UPDATE is the real arbiter under // concurrency: it only matches a pending, unexpired invite. sql.ErrNoRows // therefore means the invite was already accepted, or was revoked/expired in @@ -410,6 +392,32 @@ func (srv *InviteController) AcceptProjectInvite(w http.ResponseWriter, r *http. return } + // Ensure the admin is a member of the organization that owns this project. + // This is the cross-organization case: accepting an invite into another + // org's project makes the admin a base member of that organization so it + // appears in their organization switcher and org-scoped reads resolve. An + // existing membership (e.g. their home org) is left untouched so we never + // downgrade an owner to a member. + addedToOrg := false + var orgID uuid.UUID + if project.OrganizationID != nil { + orgID = *project.OrganizationID + isMember, err := managementStore.IsMember(ctx, orgID, adminID) + if err != nil { + logger.Error("failed to check organization membership", zap.Error(err)) + oapi.WriteProblem(w, err) + return + } + if !isMember { + if err := managementStore.AddMember(ctx, orgID, adminID, "member"); err != nil { + logger.Error("failed to add organization membership", zap.Error(err)) + oapi.WriteProblem(w, err) + return + } + addedToOrg = true + } + } + if err = tx.Commit(); err != nil { logger.Error("failed to commit transaction", zap.Error(err)) oapi.WriteProblem(w, err) @@ -439,6 +447,14 @@ func (srv *InviteController) AcceptProjectInvite(w http.ResponseWriter, r *http. return } + if addedToOrg { + if err = srv.engine.WriteTuples(ctx, access.OrganizationRoleTuples(adminID, orgID, "member")); err != nil { + logger.Error("failed to write organization RBAC tuples", zap.Error(err)) + oapi.WriteProblem(w, problem.ErrInternal(problem.Describe("failed to assign organization membership"))) + return + } + } + logger.Info("accepted project invite and added admin to project", zap.String("project_id", invite.ProjectID.String())) json.Write(w, http.StatusOK, project.OAPI()) } diff --git a/internal/http/controllers/v1/management/membership.go b/internal/http/controllers/v1/management/membership.go new file mode 100644 index 000000000..292234c56 --- /dev/null +++ b/internal/http/controllers/v1/management/membership.go @@ -0,0 +1,68 @@ +package v1 + +import ( + "context" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + "github.com/lunogram/platform/internal/rbac" + "github.com/lunogram/platform/internal/rbac/access" + "github.com/lunogram/platform/internal/store/management" +) + +// provisionMembership grants an admin membership of an organization atomically: +// it records (or revives) the organization_members row inside a database +// transaction and, only after that transaction commits, writes the RBAC tuples +// to OpenFGA. +// +// Splitting the two phases this way is deliberate. OpenFGA is not part of the +// Postgres transaction, so writing tuples first could grant access for a +// membership that later rolls back. Doing the DB work in a transaction and the +// tuple write post-commit means a partial failure can never strand a membership +// without its role tuples or, worse, leave RBAC access for a membership that was +// never persisted. The invite-accept flow follows the same ordering; this helper +// exists so the admin-management paths reuse it instead of duplicating the +// fragile sequencing. +// +// resolveAdmin runs inside the transaction and returns the admin id that the +// membership is granted to. It is where any prerequisite DB work (e.g. inserting +// a brand-new admin record) happens, sharing the transaction so it commits or +// rolls back together with the membership. The membership upsert runs after +// resolveAdmin returns successfully. +func provisionMembership( + ctx context.Context, + db *sqlx.DB, + engine *rbac.Engine, + organizationID uuid.UUID, + role string, + resolveAdmin func(ctx context.Context, tx *management.State) (uuid.UUID, error), +) (uuid.UUID, error) { + tx, err := db.BeginTxx(ctx, nil) + if err != nil { + return uuid.Nil, err + } + defer tx.Rollback() //nolint:errcheck + + store := management.NewState(tx) + + adminID, err := resolveAdmin(ctx, store) + if err != nil { + return uuid.Nil, err + } + + if err := store.AddMember(ctx, organizationID, adminID, role); err != nil { + return uuid.Nil, err + } + + if err := tx.Commit(); err != nil { + return uuid.Nil, err + } + + // Tuples are written only after the membership is durably committed; see the + // doc comment for why the ordering matters. + if err := engine.WriteTuples(ctx, access.OrganizationRoleTuples(adminID, organizationID, role)); err != nil { + return uuid.Nil, err + } + + return adminID, nil +} diff --git a/internal/http/controllers/v1/management/oapi/resources.yml b/internal/http/controllers/v1/management/oapi/resources.yml index fd898d944..0d6ed21c3 100644 --- a/internal/http/controllers/v1/management/oapi/resources.yml +++ b/internal/http/controllers/v1/management/oapi/resources.yml @@ -1653,6 +1653,53 @@ paths: default: $ref: "#/components/responses/Error" + /api/admin/organizations: + get: + summary: List my organizations + description: Lists the organizations the authenticated admin is a member of, with the active one flagged + operationId: ListMyOrganizations + tags: + - Admins + security: + - HttpBearerAuth: [] + responses: + "200": + description: Organizations retrieved successfully + content: + application/json: + schema: + type: object + required: + - results + properties: + results: + type: array + items: + $ref: "#/components/schemas/AdminOrganization" + default: + $ref: "#/components/responses/Error" + + /api/admin/active-organization: + post: + summary: Set active organization + description: Switches the authenticated admin's active organization, which scopes subsequent requests + operationId: SetActiveOrganization + tags: + - Admins + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SetActiveOrganization" + responses: + "204": + description: Active organization updated successfully + default: + $ref: "#/components/responses/Error" + /api/admin/tenant/admins: get: summary: List organization admins @@ -10010,6 +10057,40 @@ components: channel: type: string + AdminOrganization: + type: object + required: + - id + - name + - role + - is_active + properties: + id: + type: string + format: uuid + example: "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" + name: + type: string + example: "Acme Inc" + role: + type: string + description: The admin's role within this organization + example: owner + is_active: + type: boolean + description: Whether this is the admin's currently active organization + example: true + + SetActiveOrganization: + type: object + required: + - organization_id + properties: + organization_id: + type: string + format: uuid + example: "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" + CreateProjectInvite: type: object required: diff --git a/internal/http/controllers/v1/management/oapi/resources_gen.go b/internal/http/controllers/v1/management/oapi/resources_gen.go index 4dc57c38a..508c8ea81 100644 --- a/internal/http/controllers/v1/management/oapi/resources_gen.go +++ b/internal/http/controllers/v1/management/oapi/resources_gen.go @@ -796,6 +796,18 @@ type AdminList struct { Total int `json:"total"` } +// AdminOrganization defines model for AdminOrganization. +type AdminOrganization struct { + Id openapi_types.UUID `json:"id"` + + // IsActive Whether this is the admin's currently active organization + IsActive bool `json:"is_active"` + Name string `json:"name"` + + // Role The admin's role within this organization + Role string `json:"role"` +} + // ApiKey defines model for ApiKey. type ApiKey struct { CreatedAt time.Time `json:"created_at"` @@ -1860,6 +1872,11 @@ type SenderIdentity struct { // SenderIdentityChannel Channel type (email or sms) type SenderIdentityChannel string +// SetActiveOrganization defines model for SetActiveOrganization. +type SetActiveOrganization struct { + OrganizationId openapi_types.UUID `json:"organization_id"` +} + // SmsProviderData defines model for SmsProviderData. type SmsProviderData = map[string]interface{} @@ -2979,6 +2996,9 @@ type AuthCallbackParamsDriver string // AuthWebhookParamsDriver defines parameters for AuthWebhook. type AuthWebhookParamsDriver string +// SetActiveOrganizationJSONRequestBody defines body for SetActiveOrganization for application/json ContentType. +type SetActiveOrganizationJSONRequestBody = SetActiveOrganization + // CreateProjectJSONRequestBody defines body for CreateProject for application/json ContentType. type CreateProjectJSONRequestBody = CreateProject @@ -3223,6 +3243,14 @@ func WithRequestEditorFn(fn RequestEditorFn) ClientOption { // The interface specification for the client above. type ClientInterface interface { + // SetActiveOrganizationWithBody request with any body + SetActiveOrganizationWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + SetActiveOrganization(ctx context.Context, body SetActiveOrganizationJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ListMyOrganizations request + ListMyOrganizations(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetProfile request GetProfile(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -3863,6 +3891,42 @@ type ClientInterface interface { AcceptProjectInvite(ctx context.Context, inviteID openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) } +func (c *Client) SetActiveOrganizationWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetActiveOrganizationRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetActiveOrganization(ctx context.Context, body SetActiveOrganizationJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetActiveOrganizationRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ListMyOrganizations(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListMyOrganizationsRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) GetProfile(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewGetProfileRequest(c.Server) if err != nil { @@ -6635,6 +6699,73 @@ func (c *Client) AcceptProjectInvite(ctx context.Context, inviteID openapi_types return c.Client.Do(req) } +// NewSetActiveOrganizationRequest calls the generic SetActiveOrganization builder with application/json body +func NewSetActiveOrganizationRequest(server string, body SetActiveOrganizationJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewSetActiveOrganizationRequestWithBody(server, "application/json", bodyReader) +} + +// NewSetActiveOrganizationRequestWithBody generates requests for SetActiveOrganization with any type of body +func NewSetActiveOrganizationRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/admin/active-organization") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewListMyOrganizationsRequest generates requests for ListMyOrganizations +func NewListMyOrganizationsRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/admin/organizations") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewGetProfileRequest generates requests for GetProfile func NewGetProfileRequest(server string) (*http.Request, error) { var err error @@ -16264,6 +16395,14 @@ func WithBaseURL(baseURL string) ClientOption { // ClientWithResponsesInterface is the interface specification for the client with responses above. type ClientWithResponsesInterface interface { + // SetActiveOrganizationWithBodyWithResponse request with any body + SetActiveOrganizationWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetActiveOrganizationResponse, error) + + SetActiveOrganizationWithResponse(ctx context.Context, body SetActiveOrganizationJSONRequestBody, reqEditors ...RequestEditorFn) (*SetActiveOrganizationResponse, error) + + // ListMyOrganizationsWithResponse request + ListMyOrganizationsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListMyOrganizationsResponse, error) + // GetProfileWithResponse request GetProfileWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetProfileResponse, error) @@ -16904,6 +17043,69 @@ type ClientWithResponsesInterface interface { AcceptProjectInviteWithResponse(ctx context.Context, inviteID openapi_types.UUID, reqEditors ...RequestEditorFn) (*AcceptProjectInviteResponse, error) } +type SetActiveOrganizationResponse struct { + Body []byte + HTTPResponse *http.Response + JSONDefault *Error +} + +// Status returns HTTPResponse.Status +func (r SetActiveOrganizationResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r SetActiveOrganizationResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r SetActiveOrganizationResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type ListMyOrganizationsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *struct { + Results []AdminOrganization `json:"results"` + } + JSONDefault *Error +} + +// Status returns HTTPResponse.Status +func (r ListMyOrganizationsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListMyOrganizationsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r ListMyOrganizationsResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + type GetProfileResponse struct { Body []byte HTTPResponse *http.Response @@ -22409,6 +22611,32 @@ func (r AcceptProjectInviteResponse) ContentType() string { return "" } +// SetActiveOrganizationWithBodyWithResponse request with arbitrary body returning *SetActiveOrganizationResponse +func (c *ClientWithResponses) SetActiveOrganizationWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetActiveOrganizationResponse, error) { + rsp, err := c.SetActiveOrganizationWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetActiveOrganizationResponse(rsp) +} + +func (c *ClientWithResponses) SetActiveOrganizationWithResponse(ctx context.Context, body SetActiveOrganizationJSONRequestBody, reqEditors ...RequestEditorFn) (*SetActiveOrganizationResponse, error) { + rsp, err := c.SetActiveOrganization(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetActiveOrganizationResponse(rsp) +} + +// ListMyOrganizationsWithResponse request returning *ListMyOrganizationsResponse +func (c *ClientWithResponses) ListMyOrganizationsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListMyOrganizationsResponse, error) { + rsp, err := c.ListMyOrganizations(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseListMyOrganizationsResponse(rsp) +} + // GetProfileWithResponse request returning *GetProfileResponse func (c *ClientWithResponses) GetProfileWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetProfileResponse, error) { rsp, err := c.GetProfile(ctx, reqEditors...) @@ -24434,6 +24662,67 @@ func (c *ClientWithResponses) AcceptProjectInviteWithResponse(ctx context.Contex return ParseAcceptProjectInviteResponse(rsp) } +// ParseSetActiveOrganizationResponse parses an HTTP response from a SetActiveOrganizationWithResponse call +func ParseSetActiveOrganizationResponse(rsp *http.Response) (*SetActiveOrganizationResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &SetActiveOrganizationResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSONDefault = &dest + + } + + return response, nil +} + +// ParseListMyOrganizationsResponse parses an HTTP response from a ListMyOrganizationsWithResponse call +func ParseListMyOrganizationsResponse(rsp *http.Response) (*ListMyOrganizationsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ListMyOrganizationsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest struct { + Results []AdminOrganization `json:"results"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSONDefault = &dest + + } + + return response, nil +} + // ParseGetProfileResponse parses an HTTP response from a GetProfileWithResponse call func ParseGetProfileResponse(rsp *http.Response) (*GetProfileResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -30019,6 +30308,12 @@ func ParseAcceptProjectInviteResponse(rsp *http.Response) (*AcceptProjectInviteR // ServerInterface represents all server handlers. type ServerInterface interface { + // Set active organization + // (POST /api/admin/active-organization) + SetActiveOrganization(w http.ResponseWriter, r *http.Request) + // List my organizations + // (GET /api/admin/organizations) + ListMyOrganizations(w http.ResponseWriter, r *http.Request) // Get current admin profile // (GET /api/admin/profile) GetProfile(w http.ResponseWriter, r *http.Request) @@ -30556,6 +30851,18 @@ type ServerInterface interface { type Unimplemented struct{} +// Set active organization +// (POST /api/admin/active-organization) +func (_ Unimplemented) SetActiveOrganization(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// List my organizations +// (GET /api/admin/organizations) +func (_ Unimplemented) ListMyOrganizations(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + // Get current admin profile // (GET /api/admin/profile) func (_ Unimplemented) GetProfile(w http.ResponseWriter, r *http.Request) { @@ -31627,6 +31934,46 @@ type ServerInterfaceWrapper struct { type MiddlewareFunc func(http.Handler) http.Handler +// SetActiveOrganization operation middleware +func (siw *ServerInterfaceWrapper) SetActiveOrganization(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, HttpBearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.SetActiveOrganization(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ListMyOrganizations operation middleware +func (siw *ServerInterfaceWrapper) ListMyOrganizations(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, HttpBearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListMyOrganizations(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // GetProfile operation middleware func (siw *ServerInterfaceWrapper) GetProfile(w http.ResponseWriter, r *http.Request) { @@ -40217,6 +40564,12 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl ErrorHandlerFunc: options.ErrorHandlerFunc, } + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/admin/active-organization", wrapper.SetActiveOrganization) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/admin/organizations", wrapper.ListMyOrganizations) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/admin/profile", wrapper.GetProfile) }) diff --git a/internal/pubsub/consumer/broadcasts_enterprise_test.go b/internal/pubsub/consumer/broadcasts_enterprise_test.go index 9157a5a26..d26080d14 100644 --- a/internal/pubsub/consumer/broadcasts_enterprise_test.go +++ b/internal/pubsub/consumer/broadcasts_enterprise_test.go @@ -83,7 +83,9 @@ func setupBroadcastsTest(t *testing.T) ( func seedBroadcast(t *testing.T, ctx graceful.Context, mgmt *management.State, usrs *subjects.State, projectID uuid.UUID) (broadcastID, campaignID, listID uuid.UUID) { t.Helper() - providerID, err := mgmt.ProvidersStore.CreateProvider(ctx, management.Provider{ + // A provider must exist for the channel so the broadcast can resolve one + // at send time; campaigns no longer reference a provider directly. + _, err := mgmt.ProvidersStore.CreateProvider(ctx, management.Provider{ ProjectID: projectID, Module: "test", Channels: management.Channels{"email"}, @@ -93,10 +95,9 @@ func seedBroadcast(t *testing.T, ctx graceful.Context, mgmt *management.State, u require.NoError(t, err) campaignID, err = mgmt.CampaignsStore.CreateCampaign(ctx, management.Campaign{ - ProjectID: projectID, - Name: "Test Campaign", - Channel: "email", - ProviderID: &providerID, + ProjectID: projectID, + Name: "Test Campaign", + Channel: "email", }) require.NoError(t, err) @@ -228,12 +229,11 @@ func TestBroadcastProcessHandler_CampaignNoProvider(t *testing.T) { pub := pubsub.NewPublisher(jet, string(ns)) - // Create a campaign without a provider. + // Create a campaign with no provider configured for its channel. campaignID, err := mgmtState.CampaignsStore.CreateCampaign(ctx, management.Campaign{ ProjectID: projectID, Name: "No Provider Campaign", Channel: "email", - // ProviderID intentionally nil }) require.NoError(t, err) diff --git a/internal/store/management/admins.go b/internal/store/management/admins.go index e8688c3d6..19d7b03ab 100644 --- a/internal/store/management/admins.go +++ b/internal/store/management/admins.go @@ -21,16 +21,17 @@ type AdminsStore struct { } type Admin struct { - ID uuid.UUID `db:"id"` - OrganizationID uuid.UUID `db:"organization_id"` - ExternalID *string `db:"external_id"` - Email string `db:"email"` - FirstName *string `db:"first_name"` - LastName *string `db:"last_name"` - ImageURL *string `db:"image_url"` - Role string `db:"role"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + ID uuid.UUID `db:"id"` + OrganizationID uuid.UUID `db:"organization_id"` + ActiveOrganizationID *uuid.UUID `db:"active_organization_id"` + ExternalID *string `db:"external_id"` + Email string `db:"email"` + FirstName *string `db:"first_name"` + LastName *string `db:"last_name"` + ImageURL *string `db:"image_url"` + Role string `db:"role"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } func (admin *Admin) OAPI() oapi.Admin { @@ -50,7 +51,7 @@ func (admin *Admin) OAPI() oapi.Admin { func (s *AdminsStore) GetAdmin(ctx context.Context, id uuid.UUID) (*Admin, error) { stmt := ` - SELECT id, organization_id, external_id, email, first_name, last_name, image_url, role, created_at, updated_at + SELECT id, organization_id, active_organization_id, external_id, email, first_name, last_name, image_url, role, created_at, updated_at FROM admins WHERE id = $1 AND deleted_at IS NULL` @@ -66,7 +67,7 @@ func (s *AdminsStore) GetAdmin(ctx context.Context, id uuid.UUID) (*Admin, error func (s *AdminsStore) GetAdminByExternalID(ctx context.Context, externalID string) (*Admin, error) { stmt := ` - SELECT id, organization_id, external_id, email, first_name, last_name, image_url, role, created_at, updated_at + SELECT id, organization_id, active_organization_id, external_id, email, first_name, last_name, image_url, role, created_at, updated_at FROM admins WHERE external_id = $1 AND deleted_at IS NULL` @@ -100,9 +101,11 @@ func (s *AdminsStore) GetAdminBySubject(ctx context.Context, issuer, subject str } func (s *AdminsStore) CreateAdmin(ctx context.Context, admin Admin) (uuid.UUID, error) { + // A newly created admin's active organization defaults to its home + // organization; the switcher can change it later. stmt := ` - INSERT INTO admins (organization_id, external_id, email, first_name, last_name, image_url, role) - VALUES ($1, $2, $3, $4, $5, $6, $7) + INSERT INTO admins (organization_id, active_organization_id, external_id, email, first_name, last_name, image_url, role) + VALUES ($1, $1, $2, $3, $4, $5, $6, $7) RETURNING id ` @@ -127,20 +130,23 @@ func (s *AdminsStore) ListAdmins(ctx context.Context, organizationID uuid.UUID, var admins []Admin var total int + // Lists the members of the organization. Membership is the source of truth + // (an admin can belong to several organizations), so this joins + // organization_members rather than filtering admins.organization_id. query := ` SELECT - id, organization_id, external_id, email, first_name, last_name, image_url, role, created_at, updated_at, + a.id, a.organization_id, a.active_organization_id, a.external_id, a.email, a.first_name, a.last_name, a.image_url, a.role, a.created_at, a.updated_at, COUNT(*) OVER () AS total_count - FROM admins - WHERE organization_id = $1 - AND deleted_at IS NULL + FROM admins a + JOIN organization_members om ON om.admin_id = a.id AND om.organization_id = $1 AND om.deleted_at IS NULL + WHERE a.deleted_at IS NULL AND ( $2 = '' OR - first_name ILIKE '%' || $2 || '%' OR - last_name ILIKE '%' || $2 || '%' OR - email ILIKE '%' || $2 || '%' + a.first_name ILIKE '%' || $2 || '%' OR + a.last_name ILIKE '%' || $2 || '%' OR + a.email ILIKE '%' || $2 || '%' ) - ORDER BY created_at DESC + ORDER BY a.created_at DESC LIMIT $3 OFFSET $4` type result struct { @@ -172,7 +178,7 @@ func (s *AdminsStore) ListAdmins(ctx context.Context, organizationID uuid.UUID, // would let an uppercase-stored email silently bypass those checks. func (s *AdminsStore) GetAdminByEmail(ctx context.Context, email string) (*Admin, error) { stmt := ` - SELECT id, organization_id, external_id, email, first_name, last_name, image_url, role, created_at, updated_at + SELECT id, organization_id, active_organization_id, external_id, email, first_name, last_name, image_url, role, created_at, updated_at FROM admins WHERE lower(email) = lower($1) AND deleted_at IS NULL` @@ -214,6 +220,15 @@ func (s *AdminsStore) DeleteAdmin(ctx context.Context, id uuid.UUID) error { return err } +// SetActiveOrganization updates the admin's active organization, the one that +// scopes their session. Callers must verify the admin is a member of the +// organization first. +func (s *AdminsStore) SetActiveOrganization(ctx context.Context, adminID, organizationID uuid.UUID) error { + stmt := `UPDATE admins SET active_organization_id = $2 WHERE id = $1 AND deleted_at IS NULL` + _, err := s.db.ExecContext(ctx, stmt, adminID, organizationID) + return err +} + // Project Admin methods type ProjectAdmin struct { diff --git a/internal/store/management/migrations/1781000000000_organization_members.down.sql b/internal/store/management/migrations/1781000000000_organization_members.down.sql new file mode 100644 index 000000000..fc08f77e9 --- /dev/null +++ b/internal/store/management/migrations/1781000000000_organization_members.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE admins DROP COLUMN IF EXISTS active_organization_id; +DROP TABLE IF EXISTS organization_members; diff --git a/internal/store/management/migrations/1781000000000_organization_members.up.sql b/internal/store/management/migrations/1781000000000_organization_members.up.sql new file mode 100644 index 000000000..0a07560fd --- /dev/null +++ b/internal/store/management/migrations/1781000000000_organization_members.up.sql @@ -0,0 +1,37 @@ +-- Admin ↔ organization many-to-many membership. Until now every admin belonged +-- to exactly one organization via admins.organization_id; this table lets an +-- admin be a member of several organizations (e.g. after accepting a project +-- invite into another org). admins.organization_id is kept as the admin's home +-- organization for backward compatibility and is dual-written for now. +CREATE TABLE organization_members ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + admin_id UUID NOT NULL REFERENCES admins(id) ON DELETE CASCADE, + role VARCHAR(64) NOT NULL DEFAULT 'member', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + -- One row per (organization, admin) for all time. Removal is a soft delete + -- (deleted_at is set) and re-adding revives that same row through + -- AddMember's ON CONFLICT (organization_id, admin_id) upsert. A bare + -- ON CONFLICT target can only use a non-partial constraint/index as its + -- arbiter, so this full UNIQUE — not a partial "WHERE deleted_at IS NULL" + -- index — is what the upsert resolves against. Keeping both would be + -- redundant and make the arbiter ambiguous, so there is no partial index. + UNIQUE (organization_id, admin_id) +); + +CREATE INDEX organization_members_organization_id_idx ON organization_members(organization_id); +CREATE INDEX organization_members_admin_id_idx ON organization_members(admin_id); + +CREATE TRIGGER set_updated_at_organization_members BEFORE UPDATE ON organization_members FOR EACH ROW EXECUTE PROCEDURE set_updated_at(); + +-- Every existing admin becomes a member of their current home organization, +-- preserving their global role as the membership role. +INSERT INTO organization_members (organization_id, admin_id, role) +SELECT organization_id, id, role FROM admins WHERE deleted_at IS NULL; + +-- The active organization scopes an admin's session. It defaults to the home +-- organization and is changed via the organization switcher. +ALTER TABLE admins ADD COLUMN active_organization_id UUID REFERENCES organizations(id) ON DELETE SET NULL; +UPDATE admins SET active_organization_id = organization_id; diff --git a/internal/store/management/organization_members.go b/internal/store/management/organization_members.go new file mode 100644 index 000000000..f67916819 --- /dev/null +++ b/internal/store/management/organization_members.go @@ -0,0 +1,163 @@ +package management + +import ( + "context" + "time" + + "github.com/google/uuid" + "github.com/lunogram/platform/internal/store" +) + +func NewOrganizationMembersStore(db store.DB) *OrganizationMembersStore { + return &OrganizationMembersStore{db: db} +} + +type OrganizationMembersStore struct { + db store.DB +} + +type OrganizationMember struct { + ID uuid.UUID `db:"id"` + OrganizationID uuid.UUID `db:"organization_id"` + AdminID uuid.UUID `db:"admin_id"` + Role string `db:"role"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + DeletedAt *time.Time `db:"deleted_at"` +} + +// AdminOrganization is an organization an admin belongs to, with the admin's +// role in it. Used to render the organization switcher. +type AdminOrganization struct { + ID uuid.UUID `db:"id"` + Name string `db:"name"` + Role string `db:"role"` +} + +// AddMember adds the admin to the organization, or revives/updates the role of +// a previously removed membership. Idempotent. +func (s *OrganizationMembersStore) AddMember(ctx context.Context, organizationID, adminID uuid.UUID, role string) error { + stmt := ` + INSERT INTO organization_members (organization_id, admin_id, role) + VALUES ($1, $2, $3) + ON CONFLICT (organization_id, admin_id) DO UPDATE SET + role = EXCLUDED.role, + deleted_at = NULL, + updated_at = NOW()` + + _, err := s.db.ExecContext(ctx, stmt, organizationID, adminID, role) + return err +} + +// RemoveMember soft-deletes a membership. +func (s *OrganizationMembersStore) RemoveMember(ctx context.Context, organizationID, adminID uuid.UUID) error { + stmt := ` + UPDATE organization_members + SET deleted_at = NOW() + WHERE organization_id = $1 AND admin_id = $2 AND deleted_at IS NULL` + + _, err := s.db.ExecContext(ctx, stmt, organizationID, adminID) + return err +} + +// ReconcileAdminOrganizations repoints an admin's home organization_id and +// active_organization_id away from organizationID, which the admin no longer +// belongs to. It is meant to run after RemoveMember (in the same transaction) +// so correctness does not rely solely on resolveActiveOrganization's read-time +// fallback: a stale active_organization_id is cleared, and a home organization +// that points at the removed org is re-pointed to a remaining membership. +// +// active_organization_id is set NULL when it referenced the removed org (the +// column is nullable and the session falls back to the home org). The home +// organization_id is NOT NULL, so it is only re-pointed when another active +// membership exists; if the removed org was the admin's only membership the +// home pointer is left as-is (the admin has no org to belong to and read-time +// fallback still applies). +func (s *OrganizationMembersStore) ReconcileAdminOrganizations(ctx context.Context, organizationID, adminID uuid.UUID) error { + // Clear a dangling active organization first; this never fails for lack of a + // replacement because the column is nullable. + clearActive := ` + UPDATE admins + SET active_organization_id = NULL + WHERE id = $1 AND active_organization_id = $2 AND deleted_at IS NULL` + if _, err := s.db.ExecContext(ctx, clearActive, adminID, organizationID); err != nil { + return err + } + + // Re-point the home organization only when the removed org was the home org + // and the admin still has at least one other active membership to fall back + // to. organization_id is NOT NULL, so we never null it out. + repointHome := ` + UPDATE admins + SET organization_id = ( + SELECT om.organization_id + FROM organization_members om + JOIN organizations o ON o.id = om.organization_id AND o.deleted_at IS NULL + WHERE om.admin_id = $1 AND om.organization_id <> $2 AND om.deleted_at IS NULL + ORDER BY om.created_at ASC + LIMIT 1 + ) + WHERE id = $1 + AND organization_id = $2 + AND deleted_at IS NULL + AND EXISTS ( + SELECT 1 + FROM organization_members om + WHERE om.admin_id = $1 AND om.organization_id <> $2 AND om.deleted_at IS NULL + )` + if _, err := s.db.ExecContext(ctx, repointHome, adminID, organizationID); err != nil { + return err + } + + return nil +} + +// GetMember returns the admin's active membership in the organization, or +// sql.ErrNoRows if there is none. +func (s *OrganizationMembersStore) GetMember(ctx context.Context, organizationID, adminID uuid.UUID) (*OrganizationMember, error) { + stmt := ` + SELECT id, organization_id, admin_id, role, created_at, updated_at, deleted_at + FROM organization_members + WHERE organization_id = $1 AND admin_id = $2 AND deleted_at IS NULL` + + var member OrganizationMember + err := s.db.GetContext(ctx, &member, stmt, organizationID, adminID) + if err != nil { + return nil, err + } + return &member, nil +} + +// IsMember reports whether the admin is an active member of the organization. +func (s *OrganizationMembersStore) IsMember(ctx context.Context, organizationID, adminID uuid.UUID) (bool, error) { + stmt := ` + SELECT EXISTS ( + SELECT 1 FROM organization_members + WHERE organization_id = $1 AND admin_id = $2 AND deleted_at IS NULL + )` + + var exists bool + err := s.db.GetContext(ctx, &exists, stmt, organizationID, adminID) + if err != nil { + return false, err + } + return exists, nil +} + +// ListOrganizationsForAdmin returns the organizations the admin is a member of, +// ordered by name. +func (s *OrganizationMembersStore) ListOrganizationsForAdmin(ctx context.Context, adminID uuid.UUID) ([]AdminOrganization, error) { + stmt := ` + SELECT o.id, o.name, om.role + FROM organization_members om + JOIN organizations o ON o.id = om.organization_id AND o.deleted_at IS NULL + WHERE om.admin_id = $1 AND om.deleted_at IS NULL + ORDER BY o.name ASC` + + var orgs []AdminOrganization + err := s.db.SelectContext(ctx, &orgs, stmt, adminID) + if err != nil { + return nil, err + } + return orgs, nil +} diff --git a/internal/store/management/organization_members_test.go b/internal/store/management/organization_members_test.go new file mode 100644 index 000000000..5e24f1897 --- /dev/null +++ b/internal/store/management/organization_members_test.go @@ -0,0 +1,197 @@ +package management + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/lunogram/platform/internal/store" +) + +func TestOrganizationMembersStore(t *testing.T) { + t.Parallel() + db := NewContainerStore(t) + ctx := context.Background() + + orgID, err := db.CreateOrganization(ctx, "Home Org") + require.NoError(t, err) + + otherOrgID, err := db.CreateOrganization(ctx, "Other Org") + require.NoError(t, err) + + adminID, err := db.CreateAdmin(ctx, Admin{ + OrganizationID: orgID, + Email: "member@example.com", + Role: "owner", + }) + require.NoError(t, err) + + t.Run("CreateAdmin sets active organization to home org", func(t *testing.T) { + admin, err := db.GetAdmin(ctx, adminID) + require.NoError(t, err) + require.NotNil(t, admin.ActiveOrganizationID) + assert.Equal(t, orgID, *admin.ActiveOrganizationID) + }) + + t.Run("AddMember then IsMember/GetMember", func(t *testing.T) { + require.NoError(t, db.AddMember(ctx, orgID, adminID, "owner")) + + ok, err := db.IsMember(ctx, orgID, adminID) + require.NoError(t, err) + assert.True(t, ok) + + member, err := db.GetMember(ctx, orgID, adminID) + require.NoError(t, err) + assert.Equal(t, "owner", member.Role) + + ok, err = db.IsMember(ctx, otherOrgID, adminID) + require.NoError(t, err) + assert.False(t, ok) + }) + + t.Run("AddMember is idempotent and updates role", func(t *testing.T) { + require.NoError(t, db.AddMember(ctx, orgID, adminID, "admin")) + member, err := db.GetMember(ctx, orgID, adminID) + require.NoError(t, err) + assert.Equal(t, "admin", member.Role) + // restore + require.NoError(t, db.AddMember(ctx, orgID, adminID, "owner")) + }) + + t.Run("ListAdmins returns members of the organization", func(t *testing.T) { + admins, total, err := db.ListAdmins(ctx, orgID, store.Pagination{Limit: 20, Offset: 0}, "") + require.NoError(t, err) + assert.Equal(t, 1, total) + require.Len(t, admins, 1) + assert.Equal(t, adminID, admins[0].ID) + + // The admin is not a member of the other org, so it must not appear there. + _, total, err = db.ListAdmins(ctx, otherOrgID, store.Pagination{Limit: 20, Offset: 0}, "") + require.NoError(t, err) + assert.Equal(t, 0, total) + }) + + t.Run("cross-org membership and ListOrganizationsForAdmin", func(t *testing.T) { + require.NoError(t, db.AddMember(ctx, otherOrgID, adminID, "member")) + + orgs, err := db.ListOrganizationsForAdmin(ctx, adminID) + require.NoError(t, err) + require.Len(t, orgs, 2) + // ordered by name ASC: "Home Org" then "Other Org" + assert.Equal(t, "Home Org", orgs[0].Name) + assert.Equal(t, "owner", orgs[0].Role) + assert.Equal(t, "Other Org", orgs[1].Name) + assert.Equal(t, "member", orgs[1].Role) + }) + + t.Run("SetActiveOrganization", func(t *testing.T) { + require.NoError(t, db.SetActiveOrganization(ctx, adminID, otherOrgID)) + admin, err := db.GetAdmin(ctx, adminID) + require.NoError(t, err) + require.NotNil(t, admin.ActiveOrganizationID) + assert.Equal(t, otherOrgID, *admin.ActiveOrganizationID) + }) + + t.Run("RemoveMember then revive", func(t *testing.T) { + require.NoError(t, db.RemoveMember(ctx, otherOrgID, adminID)) + + ok, err := db.IsMember(ctx, otherOrgID, adminID) + require.NoError(t, err) + assert.False(t, ok) + + // Reviving a removed membership works (ON CONFLICT clears deleted_at). + require.NoError(t, db.AddMember(ctx, otherOrgID, adminID, "member")) + ok, err = db.IsMember(ctx, otherOrgID, adminID) + require.NoError(t, err) + assert.True(t, ok) + }) +} + +// TestReconcileAdminOrganizations covers the cleanup that runs after a +// membership is removed: a dangling active_organization_id is cleared, and a +// home organization pointing at the removed org is re-pointed to a remaining +// membership. +func TestReconcileAdminOrganizations(t *testing.T) { + t.Parallel() + db := NewContainerStore(t) + ctx := context.Background() + + t.Run("clears dangling active org and repoints home", func(t *testing.T) { + homeOrg, err := db.CreateOrganization(ctx, "Home") + require.NoError(t, err) + secondOrg, err := db.CreateOrganization(ctx, "Second") + require.NoError(t, err) + + adminID, err := db.CreateAdmin(ctx, Admin{ + OrganizationID: homeOrg, + Email: "reconcile@example.com", + Role: "owner", + }) + require.NoError(t, err) + require.NoError(t, db.AddMember(ctx, homeOrg, adminID, "owner")) + require.NoError(t, db.AddMember(ctx, secondOrg, adminID, "member")) + + // Active org is the home org; now remove the home membership. + require.NoError(t, db.SetActiveOrganization(ctx, adminID, homeOrg)) + require.NoError(t, db.RemoveMember(ctx, homeOrg, adminID)) + require.NoError(t, db.ReconcileAdminOrganizations(ctx, homeOrg, adminID)) + + admin, err := db.GetAdmin(ctx, adminID) + require.NoError(t, err) + // active_organization_id pointed at the removed org → cleared. + assert.Nil(t, admin.ActiveOrganizationID) + // home organization_id pointed at the removed org → re-pointed to the + // only remaining membership. + assert.Equal(t, secondOrg, admin.OrganizationID) + }) + + t.Run("leaves home org alone when removed org was not home", func(t *testing.T) { + homeOrg, err := db.CreateOrganization(ctx, "Home2") + require.NoError(t, err) + secondOrg, err := db.CreateOrganization(ctx, "Second2") + require.NoError(t, err) + + adminID, err := db.CreateAdmin(ctx, Admin{ + OrganizationID: homeOrg, + Email: "reconcile2@example.com", + Role: "owner", + }) + require.NoError(t, err) + require.NoError(t, db.AddMember(ctx, homeOrg, adminID, "owner")) + require.NoError(t, db.AddMember(ctx, secondOrg, adminID, "member")) + + // Active org is the second org; remove the second membership. + require.NoError(t, db.SetActiveOrganization(ctx, adminID, secondOrg)) + require.NoError(t, db.RemoveMember(ctx, secondOrg, adminID)) + require.NoError(t, db.ReconcileAdminOrganizations(ctx, secondOrg, adminID)) + + admin, err := db.GetAdmin(ctx, adminID) + require.NoError(t, err) + assert.Nil(t, admin.ActiveOrganizationID, "stale active org cleared") + assert.Equal(t, homeOrg, admin.OrganizationID, "home org untouched") + }) + + t.Run("keeps home org when removed org was the only membership", func(t *testing.T) { + homeOrg, err := db.CreateOrganization(ctx, "Solo") + require.NoError(t, err) + + adminID, err := db.CreateAdmin(ctx, Admin{ + OrganizationID: homeOrg, + Email: "reconcile3@example.com", + Role: "owner", + }) + require.NoError(t, err) + require.NoError(t, db.AddMember(ctx, homeOrg, adminID, "owner")) + + require.NoError(t, db.RemoveMember(ctx, homeOrg, adminID)) + require.NoError(t, db.ReconcileAdminOrganizations(ctx, homeOrg, adminID)) + + admin, err := db.GetAdmin(ctx, adminID) + require.NoError(t, err) + // No remaining membership to re-point to; home pointer stays (NOT NULL). + assert.Equal(t, homeOrg, admin.OrganizationID) + assert.Nil(t, admin.ActiveOrganizationID) + }) +} diff --git a/internal/store/management/store.go b/internal/store/management/store.go index 30796d420..e37af073e 100644 --- a/internal/store/management/store.go +++ b/internal/store/management/store.go @@ -24,6 +24,7 @@ func NewState(db store.DB) *State { ProjectPushProvidersStore: NewProjectPushProvidersStore(db), VapidKeysStore: NewVapidKeysStore(db), InvitesStore: NewInvitesStore(db), + OrganizationMembersStore: NewOrganizationMembersStore(db), } } @@ -46,4 +47,5 @@ type State struct { *ProjectPushProvidersStore *VapidKeysStore *InvitesStore + *OrganizationMembersStore }