Files
pulse/internal/models/organization.go
T

533 lines
17 KiB
Go

package models
import (
"strings"
"time"
)
// OrganizationRole represents a user's role within an organization.
type OrganizationRole string
const (
// OrgRoleOwner has full access and can manage all aspects of the organization.
OrgRoleOwner OrganizationRole = "owner"
// OrgRoleAdmin can manage resources but cannot delete the organization.
OrgRoleAdmin OrganizationRole = "admin"
// OrgRoleEditor can update organization resources but cannot manage members.
OrgRoleEditor OrganizationRole = "editor"
// OrgRoleViewer has read-only access to organization resources.
OrgRoleViewer OrganizationRole = "viewer"
)
// OrgStatus represents lifecycle status for an organization.
type OrgStatus string
const (
OrgStatusActive OrgStatus = "active"
OrgStatusSuspended OrgStatus = "suspended"
OrgStatusPendingDeletion OrgStatus = "pending_deletion"
)
// OrganizationShareStatus represents the lifecycle state of a cross-org share.
type OrganizationShareStatus string
const (
// OrganizationShareStatusPending requires explicit target-org acceptance
// before the share becomes active.
OrganizationShareStatusPending OrganizationShareStatus = "pending"
// OrganizationShareStatusAccepted is an active share that target-org admins
// have already accepted. Empty legacy status values normalize here.
OrganizationShareStatusAccepted OrganizationShareStatus = "accepted"
)
// NormalizeOrgStatus canonicalizes lifecycle status values.
// Empty status is treated as active for backward compatibility.
func NormalizeOrgStatus(status OrgStatus) OrgStatus {
switch strings.ToLower(strings.TrimSpace(string(status))) {
case "", string(OrgStatusActive):
return OrgStatusActive
case string(OrgStatusSuspended):
return OrgStatusSuspended
case string(OrgStatusPendingDeletion):
return OrgStatusPendingDeletion
default:
return OrgStatus(strings.ToLower(strings.TrimSpace(string(status))))
}
}
// NormalizeOrganizationShareStatus canonicalizes share lifecycle values.
// Empty status is treated as accepted for backward compatibility.
func NormalizeOrganizationShareStatus(status OrganizationShareStatus) OrganizationShareStatus {
switch strings.ToLower(strings.TrimSpace(string(status))) {
case "", string(OrganizationShareStatusAccepted):
return OrganizationShareStatusAccepted
case string(OrganizationShareStatusPending):
return OrganizationShareStatusPending
default:
return OrganizationShareStatus(strings.ToLower(strings.TrimSpace(string(status))))
}
}
// IsValidOrganizationShareStatus reports whether the share status is known.
func IsValidOrganizationShareStatus(status OrganizationShareStatus) bool {
switch NormalizeOrganizationShareStatus(status) {
case OrganizationShareStatusPending, OrganizationShareStatusAccepted:
return true
default:
return false
}
}
// NormalizeOrganizationRole canonicalizes role values.
func NormalizeOrganizationRole(role OrganizationRole) OrganizationRole {
switch strings.ToLower(strings.TrimSpace(string(role))) {
case string(OrgRoleOwner):
return OrgRoleOwner
case string(OrgRoleAdmin):
return OrgRoleAdmin
case string(OrgRoleEditor):
return OrgRoleEditor
case string(OrgRoleViewer):
return OrgRoleViewer
default:
return OrganizationRole(strings.ToLower(strings.TrimSpace(string(role))))
}
}
// IsValidOrganizationRole reports whether the role is a known organization role.
func IsValidOrganizationRole(role OrganizationRole) bool {
switch NormalizeOrganizationRole(role) {
case OrgRoleOwner, OrgRoleAdmin, OrgRoleEditor, OrgRoleViewer:
return true
default:
return false
}
}
func organizationRoleRank(role OrganizationRole) int {
switch NormalizeOrganizationRole(role) {
case OrgRoleViewer:
return 1
case OrgRoleEditor:
return 2
case OrgRoleAdmin:
return 3
case OrgRoleOwner:
return 4
default:
return 0
}
}
// OrganizationRoleAtLeast reports whether actualRole satisfies requiredRole.
func OrganizationRoleAtLeast(actualRole, requiredRole OrganizationRole) bool {
actualRole = NormalizeOrganizationRole(actualRole)
requiredRole = NormalizeOrganizationRole(requiredRole)
if !IsValidOrganizationRole(actualRole) || !IsValidOrganizationRole(requiredRole) {
return false
}
return organizationRoleRank(actualRole) >= organizationRoleRank(requiredRole)
}
// OrganizationMember represents a user's membership in an organization.
type OrganizationMember struct {
// UserID is the unique identifier of the member.
UserID string `json:"userId"`
// Email is the member's contact/display email. It is not the durable
// identity key; legacy records may still have email-shaped UserID values.
Email string `json:"email,omitempty"`
// Role is the member's role within the organization.
Role OrganizationRole `json:"role"`
// AddedAt is when the member was added to the organization.
AddedAt time.Time `json:"addedAt"`
// AddedBy is the user ID of who added this member (empty for owner).
AddedBy string `json:"addedBy,omitempty"`
}
// OrganizationInvitation represents pending organization access awaiting
// explicit acceptance by the invited user.
type OrganizationInvitation struct {
// UserID is the invited username.
UserID string `json:"userId"`
// Role is the role that will be granted on acceptance.
Role OrganizationRole `json:"role"`
// InvitedAt is when the invitation was created.
InvitedAt time.Time `json:"invitedAt"`
// InvitedBy is the user ID that created the invitation.
InvitedBy string `json:"invitedBy"`
}
// OrganizationShare represents a cross-organization resource/view share.
type OrganizationShare struct {
// ID is a unique identifier for the share record.
ID string `json:"id"`
// TargetOrgID is the organization receiving access.
TargetOrgID string `json:"targetOrgId"`
// ResourceType identifies what is being shared (e.g. "resource", "view").
ResourceType string `json:"resourceType"`
// ResourceID is the stable identifier of the shared resource/view.
ResourceID string `json:"resourceId"`
// ResourceName is an optional display label used by the UI.
ResourceName string `json:"resourceName,omitempty"`
// AccessRole defines the access level granted to the target organization.
AccessRole OrganizationRole `json:"accessRole"`
// Status tracks whether the target organization has accepted the share.
Status OrganizationShareStatus `json:"status"`
// CreatedAt is when the share was created.
CreatedAt time.Time `json:"createdAt"`
// CreatedBy is the user ID that created the share.
CreatedBy string `json:"createdBy"`
// AcceptedAt is when the target organization accepted the share.
AcceptedAt time.Time `json:"acceptedAt,omitempty"`
// AcceptedBy is the target-org admin who accepted the share.
AcceptedBy string `json:"acceptedBy,omitempty"`
}
// Organization represents a distinct tenant in the system.
type Organization struct {
// ID is the unique identifier for the organization (e.g., "customer-a").
// It is used as the directory name for data isolation.
ID string `json:"id"`
// DisplayName is the human-readable name of the organization.
DisplayName string `json:"displayName"`
// Status is the current lifecycle status for the organization.
// Empty status is treated as active for backward compatibility.
Status OrgStatus `json:"status,omitempty"`
// CreatedAt is when the organization was registered.
CreatedAt time.Time `json:"createdAt"`
// OwnerUserID is the primary owner of this organization.
// The owner has full administrative rights and cannot be removed.
OwnerUserID string `json:"ownerUserId,omitempty"`
// OwnerEmail is the owner's contact/display email. It is not the durable
// identity key; legacy records may still have email-shaped OwnerUserID values.
OwnerEmail string `json:"ownerEmail,omitempty"`
// Members is the list of users who have access to this organization.
// This includes the owner (with OrgRoleOwner) and any additional members.
Members []OrganizationMember `json:"members,omitempty"`
// PendingInvitations contains invited users who must explicitly accept
// organization access before membership becomes active.
PendingInvitations []OrganizationInvitation `json:"pendingInvitations,omitempty"`
// SharedResources contains outgoing cross-organization shares.
SharedResources []OrganizationShare `json:"sharedResources,omitempty"`
// SuspendedAt records when the organization was suspended.
SuspendedAt *time.Time `json:"suspendedAt,omitempty"`
// SuspendReason stores the reason for suspension, if provided.
SuspendReason string `json:"suspendReason,omitempty"`
// DeletionRequestedAt records when soft-deletion was requested.
DeletionRequestedAt *time.Time `json:"deletionRequestedAt,omitempty"`
// RetentionDays stores the soft-delete retention period in days.
RetentionDays int `json:"retentionDays,omitempty"`
// EncryptionKeyID refers to the specific encryption key used for this org's data
// (Future proofing for per-tenant encryption keys)
EncryptionKeyID string `json:"encryptionKeyId,omitempty"`
}
func normalizeOrganizationIdentityValue(value string) string {
return strings.TrimSpace(value)
}
func normalizeOrganizationEmail(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func identityLooksLikeEmail(value string) bool {
return strings.Contains(strings.TrimSpace(value), "@")
}
func memberEmail(member OrganizationMember) string {
email := normalizeOrganizationEmail(member.Email)
if email != "" {
return email
}
if identityLooksLikeEmail(member.UserID) {
return normalizeOrganizationEmail(member.UserID)
}
return ""
}
func memberMatchesUserID(member OrganizationMember, userID string) bool {
userID = normalizeOrganizationIdentityValue(userID)
return userID != "" && normalizeOrganizationIdentityValue(member.UserID) == userID
}
func memberMatchesEmail(member OrganizationMember, email string) bool {
email = normalizeOrganizationEmail(email)
return email != "" && memberEmail(member) == email
}
func ownerMatchesUserID(org *Organization, userID string) bool {
if org == nil {
return false
}
userID = normalizeOrganizationIdentityValue(userID)
return userID != "" && normalizeOrganizationIdentityValue(org.OwnerUserID) == userID
}
func ownerMatchesEmail(org *Organization, email string) bool {
if org == nil {
return false
}
email = normalizeOrganizationEmail(email)
if email == "" {
return false
}
ownerEmail := normalizeOrganizationEmail(org.OwnerEmail)
if ownerEmail != "" {
return ownerEmail == email
}
if identityLooksLikeEmail(org.OwnerUserID) {
return normalizeOrganizationEmail(org.OwnerUserID) == email
}
return false
}
func ownerMatchesLegacyEmailPrincipal(org *Organization, email string) bool {
if org == nil {
return false
}
ownerUserID := normalizeOrganizationIdentityValue(org.OwnerUserID)
if !identityLooksLikeEmail(ownerUserID) {
return false
}
return normalizeOrganizationEmail(ownerUserID) == normalizeOrganizationEmail(email)
}
func memberMatchesLegacyEmailPrincipal(member OrganizationMember, email string) bool {
userID := normalizeOrganizationIdentityValue(member.UserID)
if !identityLooksLikeEmail(userID) {
return false
}
return normalizeOrganizationEmail(userID) == normalizeOrganizationEmail(email)
}
func mergePrincipalEmailCandidate(resolvedUserID *string, resolvedRole *OrganizationRole, userID string, role OrganizationRole) bool {
userID = normalizeOrganizationIdentityValue(userID)
if userID == "" {
return false
}
role = NormalizeOrganizationRole(role)
if *resolvedUserID == "" {
*resolvedUserID = userID
*resolvedRole = role
return true
}
if *resolvedUserID != userID {
return false
}
if OrganizationRoleAtLeast(role, *resolvedRole) {
*resolvedRole = role
}
return true
}
// HasMember checks if a user is a member of the organization.
func (o *Organization) HasMember(userID string) bool {
for _, member := range o.Members {
if memberMatchesUserID(member, userID) || memberMatchesEmail(member, userID) {
return true
}
}
return false
}
// HasMemberUserID checks membership using only the stored durable user ID.
func (o *Organization) HasMemberUserID(userID string) bool {
for _, member := range o.Members {
if memberMatchesUserID(member, userID) {
return true
}
}
return false
}
// GetMemberRole returns the role of a user in the organization.
// Returns empty string if the user is not a member.
func (o *Organization) GetMemberRole(userID string) OrganizationRole {
for _, member := range o.Members {
if memberMatchesUserID(member, userID) || memberMatchesEmail(member, userID) {
return NormalizeOrganizationRole(member.Role)
}
}
return ""
}
// GetMemberRoleByUserID returns a role using only the stored durable user ID.
func (o *Organization) GetMemberRoleByUserID(userID string) OrganizationRole {
for _, member := range o.Members {
if memberMatchesUserID(member, userID) {
return NormalizeOrganizationRole(member.Role)
}
}
return ""
}
// IsOwner checks if a user is the owner of the organization.
func (o *Organization) IsOwner(userID string) bool {
return ownerMatchesUserID(o, userID) || ownerMatchesEmail(o, userID)
}
// IsOwnerUserID checks ownership using only the stored durable owner user ID.
func (o *Organization) IsOwnerUserID(userID string) bool {
return ownerMatchesUserID(o, userID)
}
// CanUserAccess checks if a user has any level of access to the organization.
func (o *Organization) CanUserAccess(userID string) bool {
if o.IsOwner(userID) {
return true
}
return o.HasMember(userID)
}
// CanUserIDAccess checks access using only durable owner/member user IDs.
func (o *Organization) CanUserIDAccess(userID string) bool {
if o.IsOwnerUserID(userID) {
return true
}
return o.HasMemberUserID(userID)
}
// CanUserManage checks if a user can manage the organization (owner or admin).
func (o *Organization) CanUserManage(userID string) bool {
if o.IsOwner(userID) {
return true
}
return OrganizationRoleAtLeast(o.GetMemberRole(userID), OrgRoleAdmin)
}
// CanUserIDManage checks management access using only durable user IDs.
func (o *Organization) CanUserIDManage(userID string) bool {
if o.IsOwnerUserID(userID) {
return true
}
return OrganizationRoleAtLeast(o.GetMemberRoleByUserID(userID), OrgRoleAdmin)
}
// GetMemberRoleForPrincipal resolves a role using the durable user ID first,
// then email only when the stored principal is still legacy email-keyed.
func (o *Organization) GetMemberRoleForPrincipal(userID, email string) OrganizationRole {
if o == nil {
return ""
}
if ownerMatchesUserID(o, userID) || ownerMatchesLegacyEmailPrincipal(o, email) {
return OrgRoleOwner
}
userID = normalizeOrganizationIdentityValue(userID)
email = normalizeOrganizationEmail(email)
for _, member := range o.Members {
if memberMatchesUserID(member, userID) || memberMatchesLegacyEmailPrincipal(member, email) {
return NormalizeOrganizationRole(member.Role)
}
}
return ""
}
// ResolvePrincipalByEmail returns the durable principal for a contact email.
// It is intended for email-delivery flows such as magic links where the email
// proves delivery but the session should still bind to the stored user ID.
func (o *Organization) ResolvePrincipalByEmail(email string) (string, OrganizationRole, bool) {
if o == nil {
return "", "", false
}
email = normalizeOrganizationEmail(email)
if email == "" {
return "", "", false
}
userID := ""
role := OrganizationRole("")
if ownerMatchesEmail(o, email) {
if !mergePrincipalEmailCandidate(&userID, &role, o.OwnerUserID, OrgRoleOwner) {
return "", "", false
}
}
for _, member := range o.Members {
if !memberMatchesEmail(member, email) {
continue
}
if !mergePrincipalEmailCandidate(&userID, &role, member.UserID, member.Role) {
return "", "", false
}
}
if userID == "" {
return "", "", false
}
return userID, role, true
}
// CanonicalizePrincipalIdentity upgrades legacy email-keyed membership records
// to a stable user ID while preserving email as contact metadata.
func (o *Organization) CanonicalizePrincipalIdentity(userID, email string) bool {
if o == nil {
return false
}
userID = normalizeOrganizationIdentityValue(userID)
email = normalizeOrganizationEmail(email)
if userID == "" || email == "" {
return false
}
changed := false
if ownerMatchesUserID(o, userID) || ownerMatchesLegacyEmailPrincipal(o, email) {
if normalizeOrganizationIdentityValue(o.OwnerUserID) != userID {
o.OwnerUserID = userID
changed = true
}
if normalizeOrganizationEmail(o.OwnerEmail) != email {
o.OwnerEmail = email
changed = true
}
}
for i := range o.Members {
member := o.Members[i]
if !memberMatchesUserID(member, userID) && !memberMatchesLegacyEmailPrincipal(member, email) {
continue
}
oldUserID := normalizeOrganizationIdentityValue(member.UserID)
if oldUserID != userID {
o.Members[i].UserID = userID
changed = true
}
if normalizeOrganizationEmail(member.Email) != email {
o.Members[i].Email = email
changed = true
}
if addedBy := normalizeOrganizationIdentityValue(member.AddedBy); addedBy != "" && (addedBy == oldUserID || normalizeOrganizationEmail(addedBy) == email) && addedBy != userID {
o.Members[i].AddedBy = userID
changed = true
}
}
return changed
}