Files
rdp-proxy/backend/internal/modules/organization/module.go
T
2026-04-28 22:29:50 +03:00

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))
}