519 lines
16 KiB
Go
519 lines
16 KiB
Go
package organization
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/example/remote-access-platform/backend/internal/platform/authority"
|
|
"github.com/example/remote-access-platform/backend/internal/platform/httpx"
|
|
"github.com/example/remote-access-platform/backend/internal/platform/module"
|
|
)
|
|
|
|
const (
|
|
RoleOrgOwner = "org_owner"
|
|
RoleOrgAdmin = "org_admin"
|
|
RoleOrgOperator = "org_operator"
|
|
RoleOrgMember = "org_member"
|
|
RoleOrgViewer = "org_viewer"
|
|
)
|
|
|
|
type Module struct {
|
|
db *pgxpool.Pool
|
|
authority *authority.Verifier
|
|
}
|
|
|
|
type Organization struct {
|
|
ID string `json:"id"`
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
Metadata json.RawMessage `json:"metadata"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type Membership struct {
|
|
ID string `json:"id"`
|
|
OrganizationID string `json:"organization_id"`
|
|
UserID string `json:"user_id"`
|
|
RoleID string `json:"role_id"`
|
|
Status string `json:"status"`
|
|
InvitedByUser *string `json:"invited_by_user_id,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type AdminSummary struct {
|
|
OrganizationID string `json:"organization_id"`
|
|
ResourceCount int64 `json:"resource_count"`
|
|
ActiveSessionCount int64 `json:"active_session_count"`
|
|
ServiceEndpoints []ServiceSummary `json:"service_endpoints"`
|
|
ConnectorStatus map[string]any `json:"connector_status"`
|
|
RecentAudit []OrgAuditEvent `json:"recent_audit"`
|
|
TopologyExposure string `json:"topology_exposure"`
|
|
}
|
|
|
|
type ServiceSummary struct {
|
|
Protocol string `json:"protocol"`
|
|
Count int64 `json:"count"`
|
|
}
|
|
|
|
type OrgAuditEvent struct {
|
|
ID string `json:"id"`
|
|
EventType string `json:"event_type"`
|
|
TargetType string `json:"target_type"`
|
|
TargetID string `json:"target_id"`
|
|
Payload json.RawMessage `json:"payload"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
type createOrganizationRequest struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
Metadata json.RawMessage `json:"metadata"`
|
|
}
|
|
|
|
type addMembershipRequest struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
UserID string `json:"user_id"`
|
|
RoleID string `json:"role_id"`
|
|
}
|
|
|
|
func NewModule(deps module.Dependencies) *Module {
|
|
authorityVerifier, _ := authority.NewVerifier(deps.Config.Installation)
|
|
return &Module{db: deps.Infra.DB, authority: authorityVerifier}
|
|
}
|
|
|
|
func (m *Module) Name() string {
|
|
return "organization"
|
|
}
|
|
|
|
func (m *Module) RegisterRoutes(router chi.Router) {
|
|
router.Route("/organizations", func(r chi.Router) {
|
|
r.Get("/", m.listOrganizations)
|
|
r.Post("/", m.createOrganization)
|
|
r.Get("/{organizationID}", m.getOrganization)
|
|
r.Get("/{organizationID}/admin-summary", m.getAdminSummary)
|
|
r.Get("/{organizationID}/memberships", m.listMemberships)
|
|
r.Post("/{organizationID}/memberships", m.addMembership)
|
|
})
|
|
}
|
|
|
|
func (m *Module) listOrganizations(w http.ResponseWriter, r *http.Request) {
|
|
userID := r.URL.Query().Get("user_id")
|
|
if userID == "" {
|
|
httpx.WriteError(w, http.StatusBadRequest, "user_id is required")
|
|
return
|
|
}
|
|
platformRole, err := m.getPlatformRole(r.Context(), userID)
|
|
if err != nil {
|
|
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
var rows pgx.Rows
|
|
if isPlatformAdmin(platformRole) {
|
|
rows, err = m.db.Query(r.Context(), `
|
|
SELECT id, slug, name, status, metadata, created_at, updated_at
|
|
FROM organizations
|
|
ORDER BY created_at DESC
|
|
`)
|
|
} else {
|
|
rows, err = m.db.Query(r.Context(), `
|
|
SELECT o.id, o.slug, o.name, o.status, o.metadata, o.created_at, o.updated_at
|
|
FROM organizations o
|
|
INNER JOIN organization_memberships om ON om.organization_id = o.id
|
|
WHERE om.user_id = $1 AND om.status = 'active'
|
|
ORDER BY o.created_at DESC
|
|
`, userID)
|
|
}
|
|
if err != nil {
|
|
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
var organizations []Organization
|
|
for rows.Next() {
|
|
org, err := scanOrganization(rows)
|
|
if err != nil {
|
|
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
organizations = append(organizations, org)
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"organizations": organizations})
|
|
}
|
|
|
|
func (m *Module) getOrganization(w http.ResponseWriter, r *http.Request) {
|
|
orgID := chi.URLParam(r, "organizationID")
|
|
userID := r.URL.Query().Get("user_id")
|
|
if userID == "" {
|
|
httpx.WriteError(w, http.StatusBadRequest, "user_id is required")
|
|
return
|
|
}
|
|
if err := m.ensureOrgAccess(r.Context(), orgID, userID, false); err != nil {
|
|
status := http.StatusInternalServerError
|
|
if errors.Is(err, pgx.ErrNoRows) || errors.Is(err, errForbidden) {
|
|
status = http.StatusForbidden
|
|
}
|
|
httpx.WriteError(w, status, err.Error())
|
|
return
|
|
}
|
|
org, err := m.getOrganizationByID(r.Context(), orgID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
httpx.WriteError(w, http.StatusNotFound, "organization not found")
|
|
return
|
|
}
|
|
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"organization": org})
|
|
}
|
|
|
|
func (m *Module) getAdminSummary(w http.ResponseWriter, r *http.Request) {
|
|
orgID := chi.URLParam(r, "organizationID")
|
|
actorUserID := r.URL.Query().Get("actor_user_id")
|
|
if actorUserID == "" {
|
|
httpx.WriteError(w, http.StatusBadRequest, "actor_user_id is required")
|
|
return
|
|
}
|
|
if err := m.ensureOrgAccess(r.Context(), orgID, actorUserID, true); err != nil {
|
|
httpx.WriteError(w, http.StatusForbidden, err.Error())
|
|
return
|
|
}
|
|
summary, err := m.loadAdminSummary(r.Context(), orgID)
|
|
if err != nil {
|
|
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"admin_summary": summary})
|
|
}
|
|
|
|
func (m *Module) loadAdminSummary(ctx context.Context, orgID string) (AdminSummary, error) {
|
|
var resourceCount int64
|
|
if err := m.db.QueryRow(ctx, `
|
|
SELECT COUNT(*)
|
|
FROM resources
|
|
WHERE organization_id = $1::uuid
|
|
`, orgID).Scan(&resourceCount); err != nil {
|
|
return AdminSummary{}, err
|
|
}
|
|
|
|
var activeSessionCount int64
|
|
if err := m.db.QueryRow(ctx, `
|
|
SELECT COUNT(*)
|
|
FROM remote_sessions
|
|
WHERE organization_id = $1::uuid
|
|
AND state = 'active'
|
|
`, orgID).Scan(&activeSessionCount); err != nil {
|
|
return AdminSummary{}, err
|
|
}
|
|
|
|
rows, err := m.db.Query(ctx, `
|
|
SELECT protocol, COUNT(*)
|
|
FROM resources
|
|
WHERE organization_id = $1::uuid
|
|
GROUP BY protocol
|
|
ORDER BY protocol
|
|
`, orgID)
|
|
if err != nil {
|
|
return AdminSummary{}, err
|
|
}
|
|
defer rows.Close()
|
|
var services []ServiceSummary
|
|
for rows.Next() {
|
|
var item ServiceSummary
|
|
if err := rows.Scan(&item.Protocol, &item.Count); err != nil {
|
|
return AdminSummary{}, err
|
|
}
|
|
services = append(services, item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return AdminSummary{}, err
|
|
}
|
|
|
|
auditRows, err := m.db.Query(ctx, `
|
|
SELECT ae.id::text, ae.event_type, ae.target_type, ae.target_id, ae.payload, ae.created_at
|
|
FROM audit_events ae
|
|
LEFT JOIN remote_sessions rs ON rs.id = ae.remote_session_id
|
|
WHERE rs.organization_id = $1::uuid
|
|
ORDER BY ae.created_at DESC
|
|
LIMIT 20
|
|
`, orgID)
|
|
if err != nil {
|
|
return AdminSummary{}, err
|
|
}
|
|
defer auditRows.Close()
|
|
var audit []OrgAuditEvent
|
|
for auditRows.Next() {
|
|
var item OrgAuditEvent
|
|
if err := auditRows.Scan(&item.ID, &item.EventType, &item.TargetType, &item.TargetID, &item.Payload, &item.CreatedAt); err != nil {
|
|
return AdminSummary{}, err
|
|
}
|
|
audit = append(audit, item)
|
|
}
|
|
if err := auditRows.Err(); err != nil {
|
|
return AdminSummary{}, err
|
|
}
|
|
|
|
return AdminSummary{
|
|
OrganizationID: orgID,
|
|
ResourceCount: resourceCount,
|
|
ActiveSessionCount: activeSessionCount,
|
|
ServiceEndpoints: services,
|
|
ConnectorStatus: map[string]any{
|
|
"vpn": "not_implemented",
|
|
"connector": "not_implemented",
|
|
},
|
|
RecentAudit: audit,
|
|
TopologyExposure: tenantSafeTopologyExposure(),
|
|
}, nil
|
|
}
|
|
|
|
func tenantSafeTopologyExposure() string {
|
|
return "tenant_safe_no_core_mesh_topology"
|
|
}
|
|
|
|
func (m *Module) createOrganization(w http.ResponseWriter, r *http.Request) {
|
|
var req createOrganizationRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid organization payload")
|
|
return
|
|
}
|
|
if req.ActorUserID == "" || req.Name == "" || req.Slug == "" {
|
|
httpx.WriteError(w, http.StatusBadRequest, "actor_user_id, slug, and name are required")
|
|
return
|
|
}
|
|
role, err := m.getPlatformRole(r.Context(), req.ActorUserID)
|
|
if err != nil {
|
|
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if !isPlatformAdmin(role) {
|
|
httpx.WriteError(w, http.StatusForbidden, "platform admin role is required")
|
|
return
|
|
}
|
|
if len(req.Metadata) == 0 {
|
|
req.Metadata = json.RawMessage(`{}`)
|
|
}
|
|
if !json.Valid(req.Metadata) {
|
|
httpx.WriteError(w, http.StatusBadRequest, "metadata must be valid json")
|
|
return
|
|
}
|
|
now := time.Now().UTC()
|
|
org := Organization{
|
|
ID: uuid.NewString(),
|
|
Slug: normalizeSlug(req.Slug),
|
|
Name: req.Name,
|
|
Status: "active",
|
|
Metadata: req.Metadata,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
membership := Membership{
|
|
ID: uuid.NewString(),
|
|
OrganizationID: org.ID,
|
|
UserID: req.ActorUserID,
|
|
RoleID: RoleOrgOwner,
|
|
Status: "active",
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
tx, err := m.db.Begin(r.Context())
|
|
if err != nil {
|
|
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
if _, err := tx.Exec(r.Context(), `
|
|
INSERT INTO organizations (id, slug, name, status, metadata, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7)
|
|
`, org.ID, org.Slug, org.Name, org.Status, []byte(org.Metadata), org.CreatedAt, org.UpdatedAt); err != nil {
|
|
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if _, err := tx.Exec(r.Context(), `
|
|
INSERT INTO organization_memberships (
|
|
id, organization_id, user_id, role_id, status, invited_by_user_id, created_at, updated_at
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
`, membership.ID, membership.OrganizationID, membership.UserID, membership.RoleID, membership.Status, req.ActorUserID, membership.CreatedAt, membership.UpdatedAt); err != nil {
|
|
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusCreated, map[string]any{
|
|
"organization": org,
|
|
"membership": membership,
|
|
})
|
|
}
|
|
|
|
func (m *Module) listMemberships(w http.ResponseWriter, r *http.Request) {
|
|
orgID := chi.URLParam(r, "organizationID")
|
|
userID := r.URL.Query().Get("user_id")
|
|
if userID == "" {
|
|
httpx.WriteError(w, http.StatusBadRequest, "user_id is required")
|
|
return
|
|
}
|
|
if err := m.ensureOrgAccess(r.Context(), orgID, userID, true); err != nil {
|
|
httpx.WriteError(w, http.StatusForbidden, err.Error())
|
|
return
|
|
}
|
|
rows, err := m.db.Query(r.Context(), `
|
|
SELECT id, organization_id, user_id, role_id, status, invited_by_user_id, created_at, updated_at
|
|
FROM organization_memberships
|
|
WHERE organization_id = $1
|
|
ORDER BY created_at DESC
|
|
`, orgID)
|
|
if err != nil {
|
|
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
var memberships []Membership
|
|
for rows.Next() {
|
|
membership, err := scanMembership(rows)
|
|
if err != nil {
|
|
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
memberships = append(memberships, membership)
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"memberships": memberships})
|
|
}
|
|
|
|
func (m *Module) addMembership(w http.ResponseWriter, r *http.Request) {
|
|
orgID := chi.URLParam(r, "organizationID")
|
|
var req addMembershipRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid membership payload")
|
|
return
|
|
}
|
|
if req.ActorUserID == "" || req.UserID == "" || req.RoleID == "" {
|
|
httpx.WriteError(w, http.StatusBadRequest, "actor_user_id, user_id, and role_id are required")
|
|
return
|
|
}
|
|
if err := m.ensureOrgAccess(r.Context(), orgID, req.ActorUserID, true); err != nil {
|
|
httpx.WriteError(w, http.StatusForbidden, err.Error())
|
|
return
|
|
}
|
|
now := time.Now().UTC()
|
|
membership := Membership{
|
|
ID: uuid.NewString(),
|
|
OrganizationID: orgID,
|
|
UserID: req.UserID,
|
|
RoleID: req.RoleID,
|
|
Status: "active",
|
|
InvitedByUser: &req.ActorUserID,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if _, err := m.db.Exec(r.Context(), `
|
|
INSERT INTO organization_memberships (
|
|
id, organization_id, user_id, role_id, status, invited_by_user_id, created_at, updated_at
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
ON CONFLICT (organization_id, user_id) DO UPDATE SET
|
|
role_id = EXCLUDED.role_id,
|
|
status = 'active',
|
|
invited_by_user_id = EXCLUDED.invited_by_user_id,
|
|
updated_at = EXCLUDED.updated_at
|
|
`, membership.ID, membership.OrganizationID, membership.UserID, membership.RoleID, membership.Status, membership.InvitedByUser, membership.CreatedAt, membership.UpdatedAt); err != nil {
|
|
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"membership": membership})
|
|
}
|
|
|
|
var errForbidden = errors.New("forbidden")
|
|
|
|
func (m *Module) ensureOrgAccess(ctx context.Context, orgID, userID string, adminRequired bool) error {
|
|
role, err := m.getPlatformRole(ctx, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if isPlatformAdmin(role) {
|
|
return nil
|
|
}
|
|
query := `
|
|
SELECT role_id
|
|
FROM organization_memberships
|
|
WHERE organization_id = $1 AND user_id = $2 AND status = 'active'
|
|
`
|
|
var roleID string
|
|
if err := m.db.QueryRow(ctx, query, orgID, userID).Scan(&roleID); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return errForbidden
|
|
}
|
|
return err
|
|
}
|
|
if adminRequired && roleID != RoleOrgOwner && roleID != RoleOrgAdmin {
|
|
return errForbidden
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Module) getPlatformRole(ctx context.Context, userID string) (string, error) {
|
|
return authority.EffectivePlatformRole(ctx, m.db, m.authority, userID)
|
|
}
|
|
|
|
func isPlatformAdmin(role string) bool {
|
|
return role == "platform_admin" || role == "platform_recovery_admin"
|
|
}
|
|
|
|
func (m *Module) getOrganizationByID(ctx context.Context, orgID string) (Organization, error) {
|
|
row := m.db.QueryRow(ctx, `
|
|
SELECT id, slug, name, status, metadata, created_at, updated_at
|
|
FROM organizations
|
|
WHERE id = $1
|
|
`, orgID)
|
|
return scanOrganization(row)
|
|
}
|
|
|
|
type rowScanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanOrganization(row rowScanner) (Organization, error) {
|
|
var org Organization
|
|
if err := row.Scan(&org.ID, &org.Slug, &org.Name, &org.Status, &org.Metadata, &org.CreatedAt, &org.UpdatedAt); err != nil {
|
|
return Organization{}, err
|
|
}
|
|
if len(org.Metadata) == 0 {
|
|
org.Metadata = json.RawMessage(`{}`)
|
|
}
|
|
return org, nil
|
|
}
|
|
|
|
func scanMembership(row rowScanner) (Membership, error) {
|
|
var membership Membership
|
|
if err := row.Scan(
|
|
&membership.ID,
|
|
&membership.OrganizationID,
|
|
&membership.UserID,
|
|
&membership.RoleID,
|
|
&membership.Status,
|
|
&membership.InvitedByUser,
|
|
&membership.CreatedAt,
|
|
&membership.UpdatedAt,
|
|
); err != nil {
|
|
return Membership{}, err
|
|
}
|
|
return membership, nil
|
|
}
|
|
|
|
func normalizeSlug(in string) string {
|
|
return strings.ToLower(strings.TrimSpace(in))
|
|
}
|