Skip to content

Memberships

A membership links a principal to an organization with a role. Any principal type can be a member, though most memberships belong to humans.

Schema

The sf_principal_memberships table contains:

Field Type Description
id UUID Primary key
principal_id UUID The member principal
organization_id UUID The organization
role string Role within the organization
permissions JSON Optional fine-grained permissions (string list)
active bool Whether the membership is in effect
created_at, updated_at time Timestamps

Unique constraint: one membership per principal per organization.

The active Flag

active lets a membership be suspended without deleting it. A membership that is inactive should be treated as absent for access decisions. The SCIM store returns only active memberships and reactivates an existing row when a member is re-added. organization.Service.ListMembers and ListMemberships return only active memberships unless organization.IncludeInactive() is passed; GetMembership returns the row whatever its active value.

Roles

Roles are strings, so each application defines its own vocabulary. The organization roles of the platform are owner, admin, member and collaborator (editor and viewer were retired; migration 0008 maps them to member and collaborator). A collaborator has no organization-wide access and holds roles on individual resources instead; see Authorization conventions for registering domain roles.

Managing Memberships

Use the organization service, which also syncs membership changes to the authorization backend:

import "github.com/plexusone/systemforge/identity/organization"

orgs := organization.NewService(client)

m, err := orgs.AddMember(ctx, organization.AddMemberInput{
    OrganizationID: orgID,
    PrincipalID:    principalID,
    Role:           "member",
    Permissions:    []string{"projects:export"},
})

AddMember returns an error if the principal is already a member.

Change Role or Suspend

role := "admin"
m, err := orgs.UpdateMember(ctx, membershipID, organization.UpdateMemberInput{Role: &role})

inactive := false
m, err = orgs.UpdateMember(ctx, membershipID, organization.UpdateMemberInput{Active: &inactive})

Remove

err := orgs.RemoveMember(ctx, orgID, principalID)

Querying Memberships

// One principal's membership in one organization.
m, err := orgs.GetMembership(ctx, orgID, principalID)

// Everyone in an organization.
members, err := orgs.ListMembers(ctx, orgID)

// Every organization a principal belongs to.
memberships, err := orgs.ListMemberships(ctx, principalID)

With Ent Directly

import "github.com/plexusone/systemforge/identity/ent/principalmembership"

admins, err := client.PrincipalMembership.Query().
    Where(
        principalmembership.OrganizationIDEQ(orgID),
        principalmembership.ActiveEQ(true),
        principalmembership.RoleIn("owner", "admin"),
    ).
    WithPrincipal().
    All(ctx)

for _, m := range admins {
    fmt.Printf("Admin: %s\n", m.Edges.Principal.DisplayName)
}

Role Hierarchy

Implement a role hierarchy in your application:

var roleLevel = map[string]int{
    "owner":        100,
    "admin":        80,
    "member":       40,
    "collaborator": 20,
}

func HasMinRole(m *organization.Membership, required string) bool {
    return m.Active && roleLevel[m.Role] >= roleLevel[required]
}

Permission Checks

func HasPermission(m *organization.Membership, permission string) bool {
    if !m.Active {
        return false
    }
    if m.Role == "owner" {
        return true
    }
    return slices.Contains(m.Permissions, permission)
}

Ownership Transfer

Transfer organization ownership from one member to another:

err := orgs.TransferOwnership(ctx, organization.TransferOwnershipInput{
    OrganizationID: orgID,
    FromPrincipal:  currentOwnerID,
    ToPrincipal:    newOwnerID,
    NewRoleForOld:  "admin", // or "member"; defaults to "admin"
})

Validation Rules

  • The current owner must have the owner role
  • The new owner must be an existing member
  • The new role for the previous owner must be admin or member
  • Ownership cannot be transferred to yourself

Transaction Safety

The transfer runs in one transaction:

  1. Demote the current owner to the specified role (default admin)
  2. Promote the new owner to owner
  3. Update the organization's owner_principal_id
  4. Sync the changes to the authorization backend

API Endpoint

POST /organizations/{slug}/transfer-ownership

Request body:

{
  "new_owner_principal_id": "uuid",
  "new_role_for_old_owner": "admin"
}

Response:

{
  "message": "Ownership transferred successfully"
}

Only the current owner can transfer ownership.