package lib import ( "encoding/json" "errors" "strings" ) var ( ErrAccessDenied = errors.New("access denied") ErrInsufficientOps = errors.New("insufficient permissions") ) // TemplateProjectID is the well-known project_id for global request templates. // All authenticated users have full rwdm access to this "project". const TemplateProjectID = "00000000-0000-0000-0000-000000000000" // 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 { return &Access{ UserID: actorID, ProjectID: projectID, Role: RoleSuperAdmin, Ops: "rwdm", }, nil } // Template project — all authenticated users have full access if projectID == TemplateProjectID && actorID != "" { return &Access{ UserID: actorID, ProjectID: TemplateProjectID, Role: RoleIBMember, Ops: "rwdm", }, nil } grants, err := getUserAccess(db, actorID, projectID) if err != nil { return nil, err } for _, g := range grants { if g.RevokedAt != nil { continue } // Grant matches if it covers all workstreams or the specific one if g.WorkstreamID != "" && g.WorkstreamID != workstreamID && workstreamID != "" { continue } if strings.Contains(g.Ops, op) { return &g, nil } } return nil, ErrAccessDenied } // CheckAccessRead is a convenience for read operations. func CheckAccessRead(db *DB, actorID, projectID, workstreamID string) error { _, err := CheckAccess(db, actorID, projectID, workstreamID, "r") return err } // CheckAccessWrite is a convenience for write operations. func CheckAccessWrite(db *DB, actorID, projectID, workstreamID string) error { _, err := CheckAccess(db, actorID, projectID, workstreamID, "w") return err } // CheckAccessDelete is a convenience for delete operations. func CheckAccessDelete(db *DB, actorID, projectID, workstreamID string) error { _, err := CheckAccess(db, actorID, projectID, workstreamID, "d") return err } // IsSuperAdmin returns true if the user has super_admin role on any project. func IsSuperAdmin(db *DB, userID string) (bool, error) { var count int err := db.Conn.QueryRow( `SELECT COUNT(*) FROM access WHERE user_id = ? AND role = ? AND revoked_at IS NULL`, userID, RoleSuperAdmin, ).Scan(&count) return count > 0, err } // IsBuyerRole returns true if the role is a buyer role. // Buyers cannot see pre_dataroom entries. func IsBuyerRole(role string) bool { return role == RoleBuyerAdmin || role == RoleBuyerMember } // CanGrantRole checks whether the granting role can grant the target role. // A role can only grant roles at or below its own hierarchy level. func CanGrantRole(granterRole, targetRole string) bool { granterLevel, ok1 := RoleHierarchy[granterRole] targetLevel, ok2 := RoleHierarchy[targetRole] if !ok1 || !ok2 { return false } return granterLevel >= targetLevel } // ResolveWorkstreamID walks up the entry tree to find the workstream ancestor. // Returns empty string if the entry is at project level (depth 0). func ResolveWorkstreamID(db *DB, entry *Entry) (string, error) { if entry.Type == TypeWorkstream { return entry.EntryID, nil } if entry.Type == TypeProject || entry.Depth == 0 { return "", nil } // Walk up to find workstream current := entry for current.Depth > 1 && current.ParentID != "" { parent, err := entryReadSystem(db, current.ParentID) if err != nil { return "", err } if parent == nil { return "", nil } if parent.Type == TypeWorkstream { return parent.EntryID, nil } current = parent } return current.EntryID, nil } // getUserAccess retrieves all active access grants for a user on a project. func getUserAccess(db *DB, userID, projectID string) ([]Access, error) { rows, err := db.Conn.Query( `SELECT id, project_id, workstream_id, user_id, role, ops, can_grant, granted_by, granted_at, revoked_at, revoked_by FROM access WHERE user_id = ? AND project_id = ? AND revoked_at IS NULL`, userID, projectID, ) if err != nil { return nil, err } defer rows.Close() var grants []Access for rows.Next() { var a Access var wsID *string var revokedAt *int64 var revokedBy *string err := rows.Scan(&a.ID, &a.ProjectID, &wsID, &a.UserID, &a.Role, &a.Ops, &a.CanGrant, &a.GrantedBy, &a.GrantedAt, &revokedAt, &revokedBy) if err != nil { return nil, err } if wsID != nil { a.WorkstreamID = *wsID } a.RevokedAt = revokedAt if revokedBy != nil { a.RevokedBy = *revokedBy } grants = append(grants, a) } return grants, rows.Err() } // GetUserHighestRole returns the highest-privilege role a user has on a project. func GetUserHighestRole(db *DB, userID, projectID string) (string, error) { grants, err := getUserAccess(db, userID, projectID) if err != nil { return "", err } bestRole := "" bestLevel := -1 for _, g := range grants { if level, ok := RoleHierarchy[g.Role]; ok && level > bestLevel { bestLevel = level bestRole = g.Role } } if bestRole == "" { return "", ErrAccessDenied } 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 }