diff --git a/SPEC.md b/SPEC.md
index e76fe31..512811c 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -881,6 +881,84 @@ CREATE INDEX idx_delegations_delegate ON delegations(delegate_id);
- Actions by delegate are logged with both `actor_id = delegate_id` and `on_behalf_of = user_id`
- User can create their own delegations; admins can create delegations for their team
+---
+
+---
+
+## 19. Organizations
+
+Organizations represent companies/firms participating in deals. They exist at the platform level and can be linked to multiple deals.
+
+### 19.1 Data Model
+
+Organizations are entries with `type: "organization"`, `depth: 0`, no parent. They live at platform level.
+
+**OrgData (packed into entry.Data):**
+```go
+type OrgData struct {
+ Name string `json:"name"`
+ Domains []string `json:"domains"` // required, e.g. ["kaseya.com","datto.com"]
+ Role string `json:"role"` // seller | buyer | ib | advisor
+ Website string `json:"website,omitempty"`
+ Description string `json:"description,omitempty"`
+ ContactName string `json:"contact_name,omitempty"`
+ ContactEmail string `json:"contact_email,omitempty"`
+}
+```
+
+A **deal_org** entry (`type: "deal_org"`, `depth: 1`, `parent = project entry_id`) links an organization into a specific deal:
+
+```go
+type DealOrgData struct {
+ OrgID string `json:"org_id"` // entry_id of the organization
+ Role string `json:"role"` // seller | buyer | ib | advisor
+ DomainLock bool `json:"domain_lock"` // if true, enforce domain check on invites
+}
+```
+
+### 19.2 Domain Validation (ELIGIBILITY ONLY)
+
+**CRITICAL SECURITY BOUNDARY:**
+
+Organization domains determine **invite eligibility only**. They answer: "Is this email address allowed to be invited to this role in this deal?"
+
+**They do NOT grant any access whatsoever.**
+
+Example: PwC (@pwc.com) is a buyer in Project James AND a seller in Project Tanya. A PwC partner invited to Project James has **ZERO visibility** into Project Tanya, even though PwC is listed there.
+
+**Rules:**
+1. `ListProjects` / `GET /api/projects` returns ONLY projects where the actor has an explicit row in the `access` table. NEVER derive project visibility from deal_org or org membership.
+2. `CheckAccess` is the single gate — it checks the `access` table, not org membership.
+3. The `deal_org` entry is queried ONLY during invite creation to validate the email domain. After that, it has no effect on access.
+4. `super_admin` bypasses this (as already implemented) — but no other role does.
+
+**ValidateOrgDomain function:**
+- Called during invite creation only
+- Finds all `deal_org` entries for the project where role matches
+- For each with `DomainLock=true`: checks if email ends with @domain for ANY domain in org.Domains
+- If no match found → returns `ErrDomainMismatch`
+- If `DomainLock=false` or no matching deal_orgs → passes through
+
+### 19.3 Multi-Domain Support
+
+Organizations can have multiple domains (e.g., post-acquisition companies). All domains are stored in the `Domains` array. Domain validation passes if the email matches **any** of the org's domains.
+
+Empty domains are not allowed. Domains are normalized to lowercase on create/update.
+
+### 19.4 API Endpoints
+
+**Organizations (platform level):**
+- `GET /api/orgs` — list all orgs (super_admin sees all; others see orgs linked to their deals)
+- `POST /api/orgs` — create org (ib_admin or super_admin only). Domains required.
+- `GET /api/orgs/{orgID}` — get org
+- `PATCH /api/orgs/{orgID}` — update org
+
+**Deal orgs (per project):**
+- `GET /api/projects/{projectID}/orgs` — list orgs in this deal
+- `POST /api/projects/{projectID}/orgs` — add org to deal `{"org_id":"...","role":"seller","domain_lock":true}`
+- `DELETE /api/projects/{projectID}/orgs/{dealOrgID}` — remove org from deal
+
+
---
*This document is the ground truth. If code disagrees with the spec, the code is wrong.*
diff --git a/api/handlers.go b/api/handlers.go
index d16c6c2..09cee57 100644
--- a/api/handlers.go
+++ b/api/handlers.go
@@ -577,6 +577,10 @@ func (h *Handlers) GetAllTasks(w http.ResponseWriter, r *http.Request) {
}
// GetAllProjects handles GET /api/projects (all projects current user has access to)
+//
+// SECURITY: Returns ONLY projects where the actor has an explicit row in the access table.
+// Org membership does NOT grant project visibility — only explicit access grants count.
+// deal_org entries are for domain validation during invite creation only.
func (h *Handlers) GetAllProjects(w http.ResponseWriter, r *http.Request) {
actorID := UserIDFromContext(r.Context())
entries, err := lib.ProjectsByUser(h.DB, h.Cfg, actorID)
@@ -923,3 +927,634 @@ func generateToken() string {
rand.Read(b)
return hex.EncodeToString(b)
}
+
+// ---------------------------------------------------------------------------
+// Organization API endpoints
+// ---------------------------------------------------------------------------
+
+// ListOrgs handles GET /api/orgs — list all organizations
+// super_admin sees all; others see orgs they're members of
+func (h *Handlers) ListOrgs(w http.ResponseWriter, r *http.Request) {
+ actorID := UserIDFromContext(r.Context())
+
+ // Check if super_admin
+ isSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)
+
+ var orgs []lib.Entry
+ var err error
+
+ if isSuperAdmin {
+ // Super admin sees all organizations
+ orgs, err = h.queryOrgsByType("")
+ } else {
+ // Others see orgs they have access to (via deal_org links in their projects)
+ orgs, err = h.queryOrgsForUser(actorID)
+ }
+
+ if err != nil {
+ ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to list organizations")
+ return
+ }
+
+ // Unpack each org's data
+ var result []map[string]any
+ for _, org := range orgs {
+ orgMap := h.orgToMap(&org)
+ result = append(result, orgMap)
+ }
+
+ if result == nil {
+ result = []map[string]any{}
+ }
+
+ JSONResponse(w, http.StatusOK, result)
+}
+
+func (h *Handlers) queryOrgsByType(role string) ([]lib.Entry, error) {
+ q := `SELECT entry_id, project_id, parent_id, type, depth,
+ search_key, search_key2, summary, data, stage,
+ assignee_id, return_to_id, origin_id,
+ version, deleted_at, deleted_by, key_version,
+ created_at, updated_at, created_by
+ FROM entries WHERE type = 'organization' AND deleted_at IS NULL
+ ORDER BY created_at DESC`
+
+ rows, err := h.DB.Conn.Query(q)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var entries []lib.Entry
+ for rows.Next() {
+ var e lib.Entry
+ err := rows.Scan(
+ &e.EntryID, &e.ProjectID, &e.ParentID, &e.Type, &e.Depth,
+ &e.SearchKey, &e.SearchKey2, &e.Summary, &e.Data, &e.Stage,
+ &e.AssigneeID, &e.ReturnToID, &e.OriginID,
+ &e.Version, &e.DeletedAt, &e.DeletedBy, &e.KeyVersion,
+ &e.CreatedAt, &e.UpdatedAt, &e.CreatedBy,
+ )
+ if err != nil {
+ return nil, err
+ }
+ entries = append(entries, e)
+ }
+ return entries, rows.Err()
+}
+
+func (h *Handlers) queryOrgsForUser(userID string) ([]lib.Entry, error) {
+ // Get all projects user has access to
+ rows, err := h.DB.Conn.Query(
+ `SELECT DISTINCT e.entry_id, e.project_id, e.parent_id, e.type, e.depth,
+ e.search_key, e.search_key2, e.summary, e.data, e.stage,
+ e.assignee_id, e.return_to_id, e.origin_id,
+ e.version, e.deleted_at, e.deleted_by, e.key_version,
+ e.created_at, e.updated_at, e.created_by
+ FROM entries e
+ WHERE e.type = 'organization' AND e.deleted_at IS NULL
+ AND e.entry_id IN (
+ SELECT DISTINCT json_extract(e2.data, '$.org_id')
+ FROM entries e2
+ JOIN access a ON a.project_id = e2.project_id
+ WHERE a.user_id = ? AND a.revoked_at IS NULL
+ AND e2.type = 'deal_org' AND e2.deleted_at IS NULL
+ )
+ ORDER BY e.created_at DESC`, userID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var entries []lib.Entry
+ for rows.Next() {
+ var e lib.Entry
+ err := rows.Scan(
+ &e.EntryID, &e.ProjectID, &e.ParentID, &e.Type, &e.Depth,
+ &e.SearchKey, &e.SearchKey2, &e.Summary, &e.Data, &e.Stage,
+ &e.AssigneeID, &e.ReturnToID, &e.OriginID,
+ &e.Version, &e.DeletedAt, &e.DeletedBy, &e.KeyVersion,
+ &e.CreatedAt, &e.UpdatedAt, &e.CreatedBy,
+ )
+ if err != nil {
+ return nil, err
+ }
+ entries = append(entries, e)
+ }
+ return entries, rows.Err()
+}
+
+func (h *Handlers) orgToMap(org *lib.Entry) map[string]any {
+ result := map[string]any{
+ "entry_id": org.EntryID,
+ "type": org.Type,
+ "created_at": org.CreatedAt,
+ "created_by": org.CreatedBy,
+ }
+
+ // Decrypt and parse org data
+ if len(org.Data) > 0 {
+ key, err := lib.DeriveProjectKey(h.Cfg.MasterKey, org.ProjectID)
+ if err == nil {
+ dataText, err := lib.Unpack(key, org.Data)
+ if err == nil {
+ var orgData lib.OrgData
+ if json.Unmarshal([]byte(dataText), &orgData) == nil {
+ result["name"] = orgData.Name
+ result["domains"] = orgData.Domains
+ result["role"] = orgData.Role
+ result["website"] = orgData.Website
+ result["description"] = orgData.Description
+ result["contact_name"] = orgData.ContactName
+ result["contact_email"] = orgData.ContactEmail
+ }
+ }
+ }
+ }
+
+ return result
+}
+
+// CreateOrg handles POST /api/orgs — create a new organization
+// ib_admin or super_admin only. Domains required.
+func (h *Handlers) CreateOrg(w http.ResponseWriter, r *http.Request) {
+ actorID := UserIDFromContext(r.Context())
+
+ // Check if user is super_admin or ib_admin in any project
+ isSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)
+ isIBAdmin := h.isIBAdminAnywhere(actorID)
+
+ if !isSuperAdmin && !isIBAdmin {
+ ErrorResponse(w, http.StatusForbidden, "access_denied", "Only IB admins or super admins can create organizations")
+ return
+ }
+
+ var req struct {
+ Name string `json:"name"`
+ Domains []string `json:"domains"`
+ Role string `json:"role"`
+ Website string `json:"website"`
+ Description string `json:"description"`
+ ContactName string `json:"contact_name"`
+ ContactEmail string `json:"contact_email"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
+ return
+ }
+
+ if req.Name == "" {
+ ErrorResponse(w, http.StatusBadRequest, "missing_fields", "Organization name required")
+ return
+ }
+ if len(req.Domains) == 0 {
+ ErrorResponse(w, http.StatusBadRequest, "missing_fields", "At least one domain required")
+ return
+ }
+ // Validate domains are not empty strings
+ for _, d := range req.Domains {
+ if strings.TrimSpace(d) == "" {
+ ErrorResponse(w, http.StatusBadRequest, "invalid_domains", "Empty domain not allowed")
+ return
+ }
+ }
+
+ // Normalize domains to lowercase
+ for i := range req.Domains {
+ req.Domains[i] = strings.ToLower(strings.TrimSpace(req.Domains[i]))
+ }
+
+ // Valid roles
+ validRoles := map[string]bool{"seller": true, "buyer": true, "ib": true, "advisor": true}
+ if req.Role != "" && !validRoles[req.Role] {
+ ErrorResponse(w, http.StatusBadRequest, "invalid_role", "Role must be one of: seller, buyer, ib, advisor")
+ return
+ }
+
+ now := time.Now().UnixMilli()
+ orgID := uuid.New().String()
+
+ orgData := lib.OrgData{
+ Name: req.Name,
+ Domains: req.Domains,
+ Role: req.Role,
+ Website: req.Website,
+ Description: req.Description,
+ ContactName: req.ContactName,
+ ContactEmail: req.ContactEmail,
+ }
+ dataJSON, _ := json.Marshal(orgData)
+
+ // Organizations use their own ID as project_id for key derivation
+ entry := &lib.Entry{
+ EntryID: orgID,
+ ProjectID: orgID, // Orgs are platform level, use own ID
+ ParentID: "",
+ Type: lib.TypeOrganization,
+ Depth: 0,
+ SummaryText: req.Name,
+ DataText: string(dataJSON),
+ Stage: lib.StageDataroom,
+ CreatedBy: actorID,
+ CreatedAt: now,
+ UpdatedAt: now,
+ Version: 1,
+ KeyVersion: 1,
+ }
+
+ // Pack encrypted fields
+ key, err := lib.DeriveProjectKey(h.Cfg.MasterKey, orgID)
+ if err != nil {
+ ErrorResponse(w, http.StatusInternalServerError, "internal", "Key derivation failed")
+ return
+ }
+ summary, err := lib.Pack(key, entry.SummaryText)
+ if err != nil {
+ ErrorResponse(w, http.StatusInternalServerError, "internal", "Encryption failed")
+ return
+ }
+ data, err := lib.Pack(key, entry.DataText)
+ if err != nil {
+ ErrorResponse(w, http.StatusInternalServerError, "internal", "Encryption failed")
+ return
+ }
+ entry.Summary = summary
+ entry.Data = data
+
+ // Direct insert (bypass RBAC since orgs are platform-level)
+ _, dbErr := h.DB.Conn.Exec(
+ `INSERT INTO entries (entry_id, project_id, parent_id, type, depth,
+ search_key, search_key2, summary, data, stage,
+ assignee_id, return_to_id, origin_id,
+ version, deleted_at, deleted_by, key_version,
+ created_at, updated_at, created_by)
+ VALUES (?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`,
+ entry.EntryID, entry.ProjectID, "", entry.Type, entry.Depth,
+ entry.SearchKey, entry.SearchKey2, entry.Summary, entry.Data, entry.Stage,
+ "", "", "",
+ entry.Version, nil, nil, entry.KeyVersion,
+ entry.CreatedAt, entry.UpdatedAt, entry.CreatedBy,
+ )
+ if dbErr != nil {
+ ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create organization")
+ return
+ }
+
+ JSONResponse(w, http.StatusCreated, map[string]any{
+ "entry_id": orgID,
+ "name": req.Name,
+ "domains": req.Domains,
+ "role": req.Role,
+ })
+}
+
+func (h *Handlers) isIBAdminAnywhere(userID string) bool {
+ var count int
+ h.DB.Conn.QueryRow(
+ `SELECT COUNT(*) FROM access WHERE user_id = ? AND role = ? AND revoked_at IS NULL`,
+ userID, lib.RoleIBAdmin,
+ ).Scan(&count)
+ return count > 0
+}
+
+// GetOrg handles GET /api/orgs/{orgID} — get a single organization
+func (h *Handlers) GetOrg(w http.ResponseWriter, r *http.Request) {
+ orgID := chi.URLParam(r, "orgID")
+
+ org, err := lib.EntryByID(h.DB, h.Cfg, orgID)
+ if err != nil || org == nil || org.Type != lib.TypeOrganization {
+ ErrorResponse(w, http.StatusNotFound, "not_found", "Organization not found")
+ return
+ }
+
+ JSONResponse(w, http.StatusOK, h.orgToMap(org))
+}
+
+// UpdateOrg handles PATCH /api/orgs/{orgID} — update an organization
+func (h *Handlers) UpdateOrg(w http.ResponseWriter, r *http.Request) {
+ actorID := UserIDFromContext(r.Context())
+ orgID := chi.URLParam(r, "orgID")
+
+ // Check permissions
+ isSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)
+ isIBAdmin := h.isIBAdminAnywhere(actorID)
+ if !isSuperAdmin && !isIBAdmin {
+ ErrorResponse(w, http.StatusForbidden, "access_denied", "Only IB admins or super admins can update organizations")
+ return
+ }
+
+ // Get existing org
+ org, err := lib.EntryByID(h.DB, h.Cfg, orgID)
+ if err != nil || org == nil || org.Type != lib.TypeOrganization {
+ ErrorResponse(w, http.StatusNotFound, "not_found", "Organization not found")
+ return
+ }
+
+ var req struct {
+ Name *string `json:"name"`
+ Domains []string `json:"domains"`
+ Role *string `json:"role"`
+ Website *string `json:"website"`
+ Description *string `json:"description"`
+ ContactName *string `json:"contact_name"`
+ ContactEmail *string `json:"contact_email"`
+ Version int `json:"version"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
+ return
+ }
+
+ // Parse existing org data
+ var orgData lib.OrgData
+ if org.DataText != "" {
+ json.Unmarshal([]byte(org.DataText), &orgData)
+ }
+
+ // Apply updates
+ if req.Name != nil {
+ orgData.Name = *req.Name
+ }
+ if req.Domains != nil {
+ if len(req.Domains) == 0 {
+ ErrorResponse(w, http.StatusBadRequest, "missing_fields", "At least one domain required")
+ return
+ }
+ for i := range req.Domains {
+ req.Domains[i] = strings.ToLower(strings.TrimSpace(req.Domains[i]))
+ }
+ orgData.Domains = req.Domains
+ }
+ if req.Role != nil {
+ validRoles := map[string]bool{"seller": true, "buyer": true, "ib": true, "advisor": true, "": true}
+ if !validRoles[*req.Role] {
+ ErrorResponse(w, http.StatusBadRequest, "invalid_role", "Role must be one of: seller, buyer, ib, advisor")
+ return
+ }
+ orgData.Role = *req.Role
+ }
+ if req.Website != nil {
+ orgData.Website = *req.Website
+ }
+ if req.Description != nil {
+ orgData.Description = *req.Description
+ }
+ if req.ContactName != nil {
+ orgData.ContactName = *req.ContactName
+ }
+ if req.ContactEmail != nil {
+ orgData.ContactEmail = *req.ContactEmail
+ }
+
+ dataJSON, _ := json.Marshal(orgData)
+ org.DataText = string(dataJSON)
+ org.SummaryText = orgData.Name
+ org.Version = req.Version
+
+ if err := lib.EntryWrite(h.DB, h.Cfg, actorID, org); err != nil {
+ if err == lib.ErrVersionConflict {
+ ErrorResponse(w, http.StatusConflict, "version_conflict", err.Error())
+ return
+ }
+ ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to update organization")
+ return
+ }
+
+ JSONResponse(w, http.StatusOK, h.orgToMap(org))
+}
+
+// ---------------------------------------------------------------------------
+// Deal Org API endpoints (per-project organization links)
+// ---------------------------------------------------------------------------
+
+// ListDealOrgs handles GET /api/projects/{projectID}/orgs — list orgs in this deal
+func (h *Handlers) ListDealOrgs(w http.ResponseWriter, r *http.Request) {
+ actorID := UserIDFromContext(r.Context())
+ projectID := chi.URLParam(r, "projectID")
+
+ // Check read access to project
+ if err := lib.CheckAccessRead(h.DB, actorID, projectID, ""); err != nil {
+ ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied")
+ return
+ }
+
+ // Get all deal_org entries for this project
+ rows, err := h.DB.Conn.Query(
+ `SELECT entry_id, project_id, parent_id, type, depth,
+ search_key, search_key2, summary, data, stage,
+ assignee_id, return_to_id, origin_id,
+ version, deleted_at, deleted_by, key_version,
+ created_at, updated_at, created_by
+ FROM entries WHERE project_id = ? AND type = 'deal_org' AND deleted_at IS NULL
+ ORDER BY created_at DESC`, projectID,
+ )
+ if err != nil {
+ ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to list deal organizations")
+ return
+ }
+ defer rows.Close()
+
+ projectKey, _ := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID)
+
+ var result []map[string]any
+ for rows.Next() {
+ var e lib.Entry
+ err := rows.Scan(
+ &e.EntryID, &e.ProjectID, &e.ParentID, &e.Type, &e.Depth,
+ &e.SearchKey, &e.SearchKey2, &e.Summary, &e.Data, &e.Stage,
+ &e.AssigneeID, &e.ReturnToID, &e.OriginID,
+ &e.Version, &e.DeletedAt, &e.DeletedBy, &e.KeyVersion,
+ &e.CreatedAt, &e.UpdatedAt, &e.CreatedBy,
+ )
+ if err != nil {
+ continue
+ }
+
+ dealOrgMap := map[string]any{
+ "deal_org_id": e.EntryID,
+ "created_at": e.CreatedAt,
+ "version": e.Version,
+ }
+
+ // Decrypt deal_org data
+ if len(e.Data) > 0 {
+ dataText, err := lib.Unpack(projectKey, e.Data)
+ if err == nil {
+ var dealOrgData lib.DealOrgData
+ if json.Unmarshal([]byte(dataText), &dealOrgData) == nil {
+ dealOrgMap["org_id"] = dealOrgData.OrgID
+ dealOrgMap["role"] = dealOrgData.Role
+ dealOrgMap["domain_lock"] = dealOrgData.DomainLock
+
+ // Fetch org details
+ org, err := lib.EntryByID(h.DB, h.Cfg, dealOrgData.OrgID)
+ if err == nil && org != nil {
+ orgDetails := h.orgToMap(org)
+ dealOrgMap["org_name"] = orgDetails["name"]
+ dealOrgMap["org_domains"] = orgDetails["domains"]
+ }
+ }
+ }
+ }
+
+ result = append(result, dealOrgMap)
+ }
+
+ if result == nil {
+ result = []map[string]any{}
+ }
+
+ JSONResponse(w, http.StatusOK, result)
+}
+
+// CreateDealOrg handles POST /api/projects/{projectID}/orgs — add org to deal
+func (h *Handlers) CreateDealOrg(w http.ResponseWriter, r *http.Request) {
+ actorID := UserIDFromContext(r.Context())
+ projectID := chi.URLParam(r, "projectID")
+
+ // Check write access to project
+ if err := lib.CheckAccessWrite(h.DB, actorID, projectID, ""); err != nil {
+ ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied")
+ return
+ }
+
+ var req struct {
+ OrgID string `json:"org_id"`
+ Role string `json:"role"`
+ DomainLock bool `json:"domain_lock"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
+ return
+ }
+
+ if req.OrgID == "" {
+ ErrorResponse(w, http.StatusBadRequest, "missing_fields", "org_id required")
+ return
+ }
+
+ // Verify org exists
+ org, err := lib.EntryByID(h.DB, h.Cfg, req.OrgID)
+ if err != nil || org == nil || org.Type != lib.TypeOrganization {
+ ErrorResponse(w, http.StatusNotFound, "not_found", "Organization not found")
+ return
+ }
+
+ // Get project entry to use as parent
+ project, err := lib.EntryByID(h.DB, h.Cfg, projectID)
+ if err != nil || project == nil {
+ ErrorResponse(w, http.StatusNotFound, "not_found", "Project not found")
+ return
+ }
+
+ now := time.Now().UnixMilli()
+ dealOrgID := uuid.New().String()
+
+ dealOrgData := lib.DealOrgData{
+ OrgID: req.OrgID,
+ Role: req.Role,
+ DomainLock: req.DomainLock,
+ }
+ dataJSON, _ := json.Marshal(dealOrgData)
+
+ entry := &lib.Entry{
+ EntryID: dealOrgID,
+ ProjectID: projectID,
+ ParentID: projectID, // Parent is the project
+ Type: lib.TypeDealOrg,
+ Depth: 1,
+ SummaryText: req.Role,
+ DataText: string(dataJSON),
+ Stage: lib.StagePreDataroom,
+ CreatedBy: actorID,
+ CreatedAt: now,
+ UpdatedAt: now,
+ Version: 1,
+ KeyVersion: 1,
+ }
+
+ // Pack encrypted fields
+ key, err := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID)
+ if err != nil {
+ ErrorResponse(w, http.StatusInternalServerError, "internal", "Key derivation failed")
+ return
+ }
+ summary, err := lib.Pack(key, entry.SummaryText)
+ if err != nil {
+ ErrorResponse(w, http.StatusInternalServerError, "internal", "Encryption failed")
+ return
+ }
+ data, err := lib.Pack(key, entry.DataText)
+ if err != nil {
+ ErrorResponse(w, http.StatusInternalServerError, "internal", "Encryption failed")
+ return
+ }
+ entry.Summary = summary
+ entry.Data = data
+
+ _, dbErr := h.DB.Conn.Exec(
+ `INSERT INTO entries (entry_id, project_id, parent_id, type, depth,
+ search_key, search_key2, summary, data, stage,
+ assignee_id, return_to_id, origin_id,
+ version, deleted_at, deleted_by, key_version,
+ created_at, updated_at, created_by)
+ VALUES (?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`,
+ entry.EntryID, entry.ProjectID, entry.ParentID, entry.Type, entry.Depth,
+ entry.SearchKey, entry.SearchKey2, entry.Summary, entry.Data, entry.Stage,
+ "", "", "",
+ entry.Version, nil, nil, entry.KeyVersion,
+ entry.CreatedAt, entry.UpdatedAt, entry.CreatedBy,
+ )
+ if dbErr != nil {
+ ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to add organization to deal")
+ return
+ }
+
+ // Get org name for response
+ orgName := ""
+ orgDetails := h.orgToMap(org)
+ if name, ok := orgDetails["name"].(string); ok {
+ orgName = name
+ }
+
+ JSONResponse(w, http.StatusCreated, map[string]any{
+ "deal_org_id": dealOrgID,
+ "org_id": req.OrgID,
+ "org_name": orgName,
+ "role": req.Role,
+ "domain_lock": req.DomainLock,
+ })
+}
+
+// DeleteDealOrg handles DELETE /api/projects/{projectID}/orgs/{dealOrgID} — remove org from deal
+func (h *Handlers) DeleteDealOrg(w http.ResponseWriter, r *http.Request) {
+ actorID := UserIDFromContext(r.Context())
+ projectID := chi.URLParam(r, "projectID")
+ dealOrgID := chi.URLParam(r, "dealOrgID")
+
+ // Check delete access to project
+ if err := lib.CheckAccessDelete(h.DB, actorID, projectID, ""); err != nil {
+ ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied")
+ return
+ }
+
+ // Verify deal_org exists and belongs to this project
+ entry, err := lib.EntryByID(h.DB, h.Cfg, dealOrgID)
+ if err != nil || entry == nil || entry.Type != lib.TypeDealOrg || entry.ProjectID != projectID {
+ ErrorResponse(w, http.StatusNotFound, "not_found", "Deal organization link not found")
+ return
+ }
+
+ if err := lib.EntryDelete(h.DB, actorID, projectID, dealOrgID); err != nil {
+ ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to remove organization from deal")
+ return
+ }
+
+ JSONResponse(w, http.StatusOK, map[string]string{"status": "deleted"})
+}
+
+// ServeAppOrgs serves the organizations page
+func (h *Handlers) ServeAppOrgs(w http.ResponseWriter, r *http.Request) {
+ h.serveTemplate(w, "app/orgs.html", nil)
+}
diff --git a/api/routes.go b/api/routes.go
index 9c64652..9c894b5 100644
--- a/api/routes.go
+++ b/api/routes.go
@@ -68,6 +68,17 @@ func NewRouter(db *lib.DB, cfg *lib.Config, store lib.ObjectStore, websiteFS fs.
r.Get("/projects/{projectID}/objects/{objectID}", h.DownloadObject)
// Super admin endpoints
+ // Organizations (platform level)
+ r.Get("/orgs", h.ListOrgs)
+ r.Post("/orgs", h.CreateOrg)
+ r.Get("/orgs/{orgID}", h.GetOrg)
+ r.Patch("/orgs/{orgID}", h.UpdateOrg)
+
+ // Deal orgs (per project)
+ r.Get("/projects/{projectID}/orgs", h.ListDealOrgs)
+ r.Post("/projects/{projectID}/orgs", h.CreateDealOrg)
+ r.Delete("/projects/{projectID}/orgs/{dealOrgID}", h.DeleteDealOrg)
+
r.Get("/admin/users", h.AdminListUsers)
r.Get("/admin/projects", h.AdminListProjects)
r.Get("/admin/audit", h.AdminAuditLog)
@@ -83,6 +94,7 @@ func NewRouter(db *lib.DB, cfg *lib.Config, store lib.ObjectStore, websiteFS fs.
r.Get("/app/projects", h.ServeAppProjects)
r.Get("/app/projects/{id}", h.ServeAppProject)
r.Get("/app/requests/{id}", h.ServeAppRequest)
+ r.Get("/app/orgs", h.ServeAppOrgs)
// Admin UI (super admin only, auth checked client-side)
r.Get("/admin", h.ServeAdmin)
diff --git a/lib/rbac.go b/lib/rbac.go
index 9769cdd..14fc77b 100644
--- a/lib/rbac.go
+++ b/lib/rbac.go
@@ -1,6 +1,7 @@
package lib
import (
+ "encoding/json"
"errors"
"strings"
)
@@ -13,6 +14,10 @@ var (
// CheckAccess verifies that actorID has the required operation on the given project/entry.
// op is one of "r", "w", "d", "m" (read, write, delete, manage).
// Returns the matching Access grant or an error.
+//
+// SECURITY: Org membership does NOT grant project access — only explicit access grants count.
+// The access table is the SOLE source of truth for project permissions.
+// deal_org entries are for domain validation during invite creation only.
func CheckAccess(db *DB, actorID, projectID string, workstreamID string, op string) (*Access, error) {
// super_admin bypasses all access checks — full rwdm on everything
if isSA, _ := IsSuperAdmin(db, actorID); isSA {
@@ -175,3 +180,118 @@ func GetUserHighestRole(db *DB, userID, projectID string) (string, error) {
}
return bestRole, nil
}
+
+var (
+ ErrDomainMismatch = errors.New("email domain does not match organization requirements")
+)
+
+
+// ValidateOrgDomain checks if an email is ELIGIBLE to be invited to a specific role in a deal.
+// This is called ONLY during invite creation for domain validation.
+//
+// SECURITY: This function answers "is this email allowed to be invited?" — it does NOT grant
+// any access whatsoever. Access is granted only by explicit rows in the access table.
+// Example: PwC (@pwc.com) may be a buyer in Project A and seller in Project B. A PwC partner
+// invited to Project A has ZERO visibility into Project B, even though PwC is listed there.
+//
+// Returns nil if validation passes (email domain matches or no domain_lock is enforced).
+func ValidateOrgDomain(db *DB, cfg *Config, projectID, email, role string) error {
+ // Find all deal_org entries for the project
+ rows, err := db.Conn.Query(
+ `SELECT entry_id, data FROM entries
+ WHERE project_id = ? AND type = ? AND deleted_at IS NULL`,
+ projectID, TypeDealOrg,
+ )
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+
+ // Get the project key for decryption
+ projectKey, err := DeriveProjectKey(cfg.MasterKey, projectID)
+ if err != nil {
+ return err
+ }
+
+ // Extract email domain
+ parts := strings.Split(email, "@")
+ if len(parts) != 2 {
+ return errors.New("invalid email format")
+ }
+ emailDomain := strings.ToLower(parts[1])
+
+ foundMatchingRole := false
+ for rows.Next() {
+ var entryID string
+ var dataBlob []byte
+ if err := rows.Scan(&entryID, &dataBlob); err != nil {
+ return err
+ }
+
+ // Decrypt and parse deal_org data
+ if len(dataBlob) == 0 {
+ continue
+ }
+ dataText, err := Unpack(projectKey, dataBlob)
+ if err != nil {
+ continue
+ }
+
+ var dealOrgData DealOrgData
+ if err := json.Unmarshal([]byte(dataText), &dealOrgData); err != nil {
+ continue
+ }
+
+ // Only check deal_orgs with matching role
+ if dealOrgData.Role != role {
+ continue
+ }
+ foundMatchingRole = true
+
+ // If domain lock is disabled, pass through
+ if !dealOrgData.DomainLock {
+ continue
+ }
+
+ // Get the organization entry to check domains
+ orgEntry, err := entryReadSystem(db, dealOrgData.OrgID)
+ if err != nil || orgEntry == nil {
+ continue
+ }
+
+ // Decrypt org data
+ orgKey, err := DeriveProjectKey(cfg.MasterKey, orgEntry.ProjectID)
+ if err != nil {
+ continue
+ }
+ if len(orgEntry.Data) == 0 {
+ continue
+ }
+ orgDataText, err := Unpack(orgKey, orgEntry.Data)
+ if err != nil {
+ continue
+ }
+
+ var orgData OrgData
+ if err := json.Unmarshal([]byte(orgDataText), &orgData); err != nil {
+ continue
+ }
+
+ // Check if email domain matches any of the org's domains
+ for _, domain := range orgData.Domains {
+ if strings.ToLower(domain) == emailDomain {
+ return nil // Match found, validation passes
+ }
+ }
+ }
+
+ // If no deal_orgs with matching role and domain_lock=true, pass
+ if !foundMatchingRole {
+ return nil
+ }
+
+ // Check if ALL deal_orgs with this role have domain_lock=false
+ // If we got here and foundMatchingRole=true, that means there's at least one
+ // with domain_lock=true that didn't match, so return error
+ return ErrDomainMismatch
+}
diff --git a/lib/types.go b/lib/types.go
index c96f01d..e790a88 100644
--- a/lib/types.go
+++ b/lib/types.go
@@ -47,7 +47,9 @@ const (
TypeRequestList = "request_list"
TypeRequest = "request"
TypeAnswer = "answer"
- TypeComment = "comment"
+ TypeComment = "comment"
+ TypeOrganization = "organization"
+ TypeDealOrg = "deal_org"
)
// Stages
@@ -57,6 +59,25 @@ const (
StageClosed = "closed"
)
+// OrgData is the JSON structure packed into an organization entry's Data field.
+type OrgData struct {
+ Name string `json:"name"`
+ Domains []string `json:"domains"` // required, e.g. ["kaseya.com","datto.com"]
+ Role string `json:"role"` // seller | buyer | ib | advisor
+ Website string `json:"website,omitempty"`
+ Description string `json:"description,omitempty"`
+ ContactName string `json:"contact_name,omitempty"`
+ ContactEmail string `json:"contact_email,omitempty"`
+}
+
+// DealOrgData is the JSON structure packed into a deal_org entry's Data field.
+// A deal_org entry links an organization into a specific deal (project).
+type DealOrgData struct {
+ OrgID string `json:"org_id"` // entry_id of the organization
+ Role string `json:"role"` // seller | buyer | ib | advisor
+ DomainLock bool `json:"domain_lock"` // if true, enforce domain check on invites
+}
+
// User represents an account.
type User struct {
UserID string `json:"user_id"`
diff --git a/portal/templates/admin/dashboard.html b/portal/templates/admin/dashboard.html
index 626da54..1c236be 100644
--- a/portal/templates/admin/dashboard.html
+++ b/portal/templates/admin/dashboard.html
@@ -1,240 +1,111 @@
-
-
+
Admin — Dealspace
-
-
-
-
-