Skip to content
Merged
14 changes: 14 additions & 0 deletions console/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
ActionCreateParams,
ActionUpdateParams,
Admin,
AdminOrganization,
AuthDriver,
Campaign,
CampaignCreateParams,
Expand Down Expand Up @@ -242,6 +243,19 @@ const api = {
whoami: async () => await client.get<Admin>("/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<SearchResult<AdminOrganization>>("/admin/organizations")
.then((r) => r.data),
setActive: async (organizationId: UUID) =>
await client.post("/admin/active-organization", {
organization_id: organizationId,
}),
},

projects: {
...createEntityPath<Project>("/admin/projects"),
all: async () =>
Expand Down
13 changes: 13 additions & 0 deletions console/src/components/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<Sidebar {...props}>
<SidebarHeader>
{organizations && <OrganizationSwitcher organizations={organizations} />}
{allProjects && project && (
<ProjectSwitcher projects={allProjects} currentProject={project} />
)}
Expand Down
94 changes: 94 additions & 0 deletions console/src/components/organization-switcher.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild className="w-full">
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground cursor-pointer"
>
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
{switching ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Building2 className="size-4" />
)}
</div>
<div className="flex flex-col gap-0.5 leading-none text-left">
<span className="text-xs text-muted-foreground">
{t("organization", "Organization")}
</span>
<span className="font-semibold truncate">{current.name}</span>
</div>
<ChevronsUpDown className="ml-auto" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-dropdown-menu-trigger-width] min-w-56"
align="start"
side="bottom"
sideOffset={4}
>
{organizations.map((organization) => (
<DropdownMenuItem
key={organization.id}
onSelect={() => handleSelect(organization)}
className="cursor-pointer"
>
{organization.name}
{organization.is_active && <Check className="ml-auto" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
112 changes: 112 additions & 0 deletions console/src/oapi/management.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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?: {
Expand Down
7 changes: 7 additions & 0 deletions console/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
67 changes: 66 additions & 1 deletion internal/http/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 == "" {
Expand Down
Loading
Loading