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 - - - - -
+ +
+ Dealspace
- - Dealspace - - SUPER ADMIN -
-
- ← Back to app - + Super Admin + +
- -
-

Platform Administration

- - -
- - - -
- - -
-
- - +
+
+ +
+

Admin Dashboard

+

Platform overview and management.

- -
diff --git a/portal/templates/app/orgs.html b/portal/templates/app/orgs.html new file mode 100644 index 0000000..7c13125 --- /dev/null +++ b/portal/templates/app/orgs.html @@ -0,0 +1,154 @@ + + + + + Organizations — Dealspace + + + + + +
+ Dealspace +
+ + +
+
+
+ +
+
+
+

Organizations

+

Company directory — parties eligible to participate in deals.

+
+ +
+
+
Loading...
+
+ +
+
+ + + + + + + diff --git a/portal/templates/app/project.html b/portal/templates/app/project.html new file mode 100644 index 0000000..a385a5c --- /dev/null +++ b/portal/templates/app/project.html @@ -0,0 +1,219 @@ + + + + + Project — Dealspace + + + + + +
+
+ Dealspace + / + Projects + / + Loading... +
+
+ + +
+
+
+ +
+ +
+
+
+

Loading...

+ +
+

+
+ +
+ + +
+ + + +
+ + +
+
Loading requests...
+ +
+ + + + + + +
+
+ + + + diff --git a/portal/templates/app/projects.html b/portal/templates/app/projects.html new file mode 100644 index 0000000..3065344 --- /dev/null +++ b/portal/templates/app/projects.html @@ -0,0 +1,155 @@ + + + + + Projects — Dealspace + + + + + +
+ Dealspace +
+ + +
+
+
+ +
+
+
+

Projects

+

All deals you have access to.

+
+ +
+
+
Loading projects...
+
+ +
+
+ + + + + + + diff --git a/portal/templates/app/request.html b/portal/templates/app/request.html new file mode 100644 index 0000000..3938a67 --- /dev/null +++ b/portal/templates/app/request.html @@ -0,0 +1,204 @@ + + + + + Request — Dealspace + + + + + +
+
+ Dealspace + / + Projects + / + Project + / + Request +
+
+ + +
+
+
+ +
+ +
+
+
+

Loading...

+

+
+ +
+
+ + +
+
+ + +
+

Response

+
+
+
📎
+

Drop files here or click to upload

+

PDF, DOCX, XLSX, images

+ +
+
+
+ + +
+

Comments

+
+
+ + +
+
+
+
+ + + + diff --git a/portal/templates/auth/login.html b/portal/templates/auth/login.html index e3a9f40..2ff5788 100644 --- a/portal/templates/auth/login.html +++ b/portal/templates/auth/login.html @@ -77,7 +77,11 @@
-

© 2026 Muskepo B.V. — Amsterdam

+

+ Don’t have an account? Dealspace is invite-only.
+ Request access on muskepo.com +

+

© 2026 Muskepo B.V. — Amsterdam