mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 18:45:53 +00:00
533 lines
17 KiB
Go
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
|
|
}
|