Initial project snapshot
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
package organization
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTenantSafeTopologyExposureDoesNotExposeCoreMesh(t *testing.T) {
|
||||
value := tenantSafeTopologyExposure()
|
||||
forbidden := []string{
|
||||
"core_node_id",
|
||||
"mesh_route",
|
||||
"cluster_private_topology",
|
||||
"certificate_serial",
|
||||
}
|
||||
for _, token := range forbidden {
|
||||
if value == token {
|
||||
t.Fatalf("topology exposure leaked forbidden token %q", token)
|
||||
}
|
||||
}
|
||||
if value != "tenant_safe_no_core_mesh_topology" {
|
||||
t.Fatalf("unexpected topology exposure marker: %q", value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user