Organizations Subgraph
The Organizations subgraph powers collaborative Malet ownership on Ngwenya. It provides team-based access control, invitation workflows, vertical-specific role assignment, and comprehensive audit logging โ enabling the transformation from single-owner Malets to enterprise-scale collaborative storefronts.
Port:
3006ยท Federation: Apollo v2 ยท Database: MongoDB (organizations,memberships,invitations,org_audit_eventscollections)
Architecture
The subgraph uses a domain-partitioned directory structure with a single flat NestJS module for dependency injection stability:
apps/organizations/src/
โโโ organization/ โ Core entity, resolvers, types, loader, malet federation
โโโ membership/ โ Membership entity, resolver, permissions service, vertical roles
โโโ invitation/ โ Invitation entity, resolver, token-based acceptance
โโโ audit/ โ OrgAuditEvent entity, service, resolver
โโโ webhook/ โ WebhookSubscription, WebhookDelivery, dispatch service for platform events
โโโ guards/ โ PermissionsGuard (RBAC enforcement)
โโโ decorators/ โ @RequirePermission (shared via @app/common)
All providers are registered in a single OrganizationModule โ the directory separation is purely organizational. Cross-subgraph shared types (Permission enum, RequirePermission decorator) are exported from @app/common.
Core Entities
Organization
The top-level container for team collaboration. A Malet Owner creates an Organization, then invites team members.
type Organization @key(fields: "id") {
id: ID!
orgID: String! # Human-readable identifier (e.g. "ORG-A1B2C3")
name: String!
slug: String! # URL-safe identifier
description: String
category: String # Vertical type (tour, restaurant, photographer, author)
membersCount: Int!
maletsCount: Int!
settings: OrgSettings # Billing + notification preferences
members: [Membership!]! # Relay connection
activityFeeds: OrgAuditEventConnection! # Cursor-paginated audit events
createdAt: DateTime!
updatedAt: DateTime!
}
Membership
Represents a user's membership in an organization with role and optional vertical-specific role.
type Membership @key(fields: "id") {
id: ID!
organizationId: String!
userId: String!
role: OrgRole! # OWNER | ADMIN | MEMBER | VIEWER
verticalRole: VerticalRoleType # GUIDE | KITCHEN | PHOTOGRAPHER | etc.
permissions: [Permission!]! # Computed from role + verticalRole
organization: Organization!
joinedAt: DateTime!
}
Invitation
Token-based invitation for joining an organization. Invitations expire after 7 days and support revocation.
type Invitation @key(fields: "id") {
id: ID!
token: String! # 32-char secure token
organizationId: String!
email: String!
role: OrgRole!
status: InvitationStatus! # PENDING | ACCEPTED | REVOKED | EXPIRED
invitedBy: String!
expiresAt: DateTime!
acceptedAt: DateTime
}
Developer Platform Webhooks
Webhooks allow organizations to subscribe to general platform events (commerce, product lifecycle). This uses a fan-out dispatch system with HMAC-SHA256 signature verification and exponential backoff retry.
type WebhookSubscription {
id: ID!
organizationId: ID!
maletId: ID
name: String!
url: String!
events: [PlatformEventType!]!
isActive: Boolean!
}
type WebhookDelivery {
id: ID!
subscriptionId: ID!
eventType: PlatformEventType!
status: WebhookDeliveryStatus!
httpStatus: Int
attempts: Int!
error: String
createdAt: DateTime!
}
Role-Based Access Control (RBAC)
Role Hierarchy
OWNER โ ADMIN โ MEMBER โ VIEWER
| Role | Capabilities |
|---|---|
| OWNER | Full control โ all permissions granted. Can delete the Organization. |
| ADMIN | All permissions. Can manage members, roles, and settings. |
| MEMBER | Base analytics + vertical-specific permissions from verticalRole. |
| VIEWER | Read-only analytics access. |
Vertical Roles
Members can be assigned a vertical-specific role that grants additional fine-grained permissions beyond their base OrgRole. These are matched to the Organization's category.
| Vertical | Roles | Key Permissions |
|---|---|---|
| Tour Operator | GUIDE, BOOKING_MANAGER |
VIEW_MANIFESTS, MANAGE_BOOKINGS, CONTACT_GUESTS |
| Restaurant | KITCHEN, SERVER |
ACCESS_KDS, UPDATE_ORDER_STATUS, CREATE_ORDERS |
| Photography | PHOTOGRAPHER, PHOTOGRAPHY_EDITOR |
UPLOAD_IMAGES, MANAGE_GALLERIES, ACCESS_PROOFING |
| Author | COAUTHOR, AUTHOR_EDITOR |
EDIT_BLOGS, EDIT_PRODUCTS |
Permission Computation
Effective permissions are computed at runtime by combining base role permissions with vertical role permissions:
// PermissionsService.computePermissions()
const effective = [
...BASE_ROLE_PERMISSIONS[membership.role], // From OrgRole
...VERTICAL_ROLE_PERMISSIONS[membership.verticalRole] // From VerticalRoleType
];
// โ Deduplicated Permission[] array
Using `@RequirePermission`
The @RequirePermission decorator and Permission enum are available from @app/common for any subgraph:
import { RequirePermission, Permission } from '@app/common';
@UseGuards(PermissionsGuard)
@RequirePermission(Permission.ACCESS_KDS)
@Query(() => [Order])
async kitchenQueue(@Args('orgId') orgId: string) {
// Only members with ACCESS_KDS permission can reach this
}
The PermissionsGuard automatically:
- Extracts
x-user-idfrom the request header - Extracts
x-org-idfrom headers or arguments - Looks up the user's
Membershipin the Organization - Computes permissions from
role+verticalRole - Checks against the required
Permission
Invitation Flow
1. Admin calls sendInvitation(orgId, email, role)
โ
2. System creates Invitation with 32-char token (7-day expiry)
โ
3. Event emitted โ Alerts service sends email with invitation link
โ
4. Invitee calls acceptInvitation(token)
โ
5. Membership created, Organization.membersCount incremented
โ
6. Invitation status โ ACCEPTED
Security
- Tokens are single-use and expire after 7 days
- Only
OWNERorADMINroles can send/revoke invitations - Duplicate pending invitations for the same email are rejected
- Already-accepted invitations cannot be reused
Audit System
All organization actions are logged as OrgAuditEvent records with cursor-based pagination.
Event Types
| Event | Trigger | Metadata |
|---|---|---|
ORG_CREATED |
Organization created | { name } |
ORG_UPDATED |
Settings or profile changed | { settings: { billing, notifications } } |
ORG_DELETED |
Organization deleted | โ |
MEMBER_INVITED |
Invitation sent | { email, role } |
MEMBER_JOINED |
Invitation accepted | { role } |
MEMBER_REMOVED |
Member removed or left | { userId } |
ROLE_CHANGED |
Role updated | { oldRole, newRole } |
VERTICAL_ROLE_CHANGED |
Vertical role assigned | { verticalRole } |
Querying the Audit Log
query OrgAuditEvents($orgId: String!, $limit: Int) {
organizationAuditEvents(orgId: $orgId, limit: $limit) {
id
eventType
actorId
actor {
id
displayName
}
targetUserId
targetUser {
id
displayName
}
metadata
createdAt
}
}
Access requires
VIEW_AUDIT_LOGSpermission (granted toOWNERandADMINroles).
Organization Lifecycle
Creation
When an organization is created:
createOrganizationmutation validates name/slug uniqueness- Organization record created with
membersCount: 1,maletsCount: 0 - Owner membership auto-created with
role: OWNER - Events emitted:
bootstrap_org_subscriptionโ payments service creates free Starter subscriptionorg_createdโ alerts service sends welcome notification
Deletion (Safety-Guarded)
Deleting an organization requires all safety checks to pass:
| Check | Guard | Error if failed |
|---|---|---|
| No active paid subscription | TCP query to get_active_tier({ orgId }) |
"Cannot delete organization with active PRO subscription. Cancel your subscription from the Billing page first." |
| No owned Malets | org.maletsCount === 0 |
"Cannot delete organization that owns 3 Malet(s). Transfer or delete all Malets before deleting the organization." |
| Owner role required | @RequirePermission(Permission.DELETE_ORG) |
"Insufficient permissions" |
Once all checks pass, the cascade is:
1. Delete all Memberships (organizationId)
2. Delete all Invitations (organizationId)
3. Delete Organization record
4. Create ORG_DELETED audit log
5. Emit organization_deleted โ alerts (notification)
Important: This follows GitHub's organization deletion model. Users must explicitly cancel their subscription and transfer/delete all Malets before the organization can be removed.
Malet Ownership Transfer
Two mutations enable bidirectional Malet ownership changes:
# Personal โ Organization
transferMaletToOrganization(maletId: String!, targetOrgId: String!): Malet!
# Organization โ Personal
transferMaletToUser(maletId: String!): Malet!
Transfer rules:
- Personal โ Org: Only the current owner can transfer. Sets
ownerId = targetOrgId,ownerType = ORGANIZATION - Org โ Personal: Sets
ownerId = actor.id,ownerType = USER, clearsorgId - Transfer is instant โ all products, orders, and history move with the Malet
- Transfer is reversible (transfer back at any time)
Known limitation:
transferMaletToOrganizationdoes not verify the actor's org membership role. A TODO exists to add admin-level org membership verification via cross-service call.
GraphQL API Reference
Mutations
# Organization Lifecycle
createOrganization(input: CreateOrganizationInput!): Organization!
updateOrganization(input: UpdateOrganizationInput!): Organization!
deleteOrganization(id: ID!): Organization! # Safety-guarded (see Lifecycle)
# Malet Ownership Transfer
transferMaletToOrganization(maletId: String!, targetOrgId: String!): Malet!
transferMaletToUser(maletId: String!): Malet!
# Membership Management
inviteMember(input: InviteMemberInput!): Organization!
removeMember(orgId: ID!, userId: ID!): Organization!
updateMemberRole(input: UpdateMemberRoleInput!): Organization!
updateMemberVerticalRole(input: UpdateMemberVerticalRoleInput!): Organization!
# Invitation Workflow
sendInvitation(orgId: ID!, email: String!, role: String): Invitation!
acceptInvitation(token: String!): Membership!
revokeInvitation(invitationId: String!): Invitation!
# Webhook Management
createWebhookSubscription(input: CreateWebhookSubscriptionInput!): WebhookSubscriptionWithSecret!
updateWebhookSubscription(input: UpdateWebhookSubscriptionInput!): WebhookSubscription!
deleteWebhookSubscription(orgId: ID!, webhookId: ID!): WebhookSubscription!
rotateWebhookSecret(orgId: ID!, webhookId: ID!): WebhookSubscriptionWithSecret!
testWebhookSubscription(orgId: ID!, webhookId: ID!): WebhookTestResult!
# Notification Testing
sendTestSms(phone: String!, message: String!): Boolean!
Queries
# Organization Lookup
organization(id: ID!): Organization
organizations(filter: OrganizationFilter): OrganizationConnection!
userOrganizations(userId: ID!): [Membership!]!
# Audit & Activity
organizationAuditEvents(orgId: String!, limit: Int, eventType: OrgAuditEventType): [OrgAuditEvent!]!
organizationActivityFeed(orgId: ID!, limit: Int): [OrgAuditEvent!]!
# Invitation
pendingInvitations(orgId: String!): [Invitation!]!
# Webhooks
webhookSubscriptions(orgId: ID!): [WebhookSubscription!]!
webhookDeliveries(orgId: ID!, webhookId: ID!, limit: Int, status: WebhookDeliveryStatus): [WebhookDelivery!]!
Federation Integration
Malet Entity Resolution
The Malet entity is resolved via federation โ when a Malet has an orgId, the Organizations subgraph resolves the associated organization field using a DataLoader-backed batch query:
extend type Malet @key(fields: "id") {
id: ID! @external
orgId: String @external
organization: Organization @requires(fields: "orgId")
}
Context Propagation
All Organization-scoped operations use header-based context:
POST /graphql
x-user-id: user_123
x-org-id: org_456
Cookie: __session=...
The PermissionsGuard also supports argument-based org context (orgId or input.orgId) for mutations that include the org ID in their input payload.
Cross-Service Dependencies
| Service | Integration |
|---|---|
| auth | User identity via x-user-id header |
| malets | Malet federation (Malet.orgId โ Organization) |
| nodes | Actor resolution for member display names |
| alerts | Invitation email dispatch (org_invite_created event) |
| payments | Subscription tier check via TCP (get_active_tier) for deletion guards |
| gateway | Context propagation via x-org-id header |
Testing
| Suite | Tests | Coverage |
|---|---|---|
| Organization Resolver | 12 | Create, invite, update, remove, delete |
| Invitation Resolver | 10 | Send, accept, revoke, error handling |
| Permissions Service | 10 | Role computation, vertical roles, dedup |
| Permissions Guard | 7 | Auth, org context, role enforcement |
| Audit Service | 5 | Event creation, filtering, user events |
| Organization Entity | 3 | Entity instantiation |
| E2E | 5 | Audit creation, role changes, access control, settings, activity feed |
| Total | 57 | โ |
Related
- Corporate Identity (SAML & SCIM) โ Enterprise SSO and automated provisioning that creates Organization memberships via SAML/SCIM
- SCIM โ Provisioning Engine โ SCIM 2.0 service that provisions users and groups into Organizations
- Teams & Sub-Groups โ Group Organization members into named teams with LEAD/MEMBER roles
- Custom RBAC โ Admin-defined custom roles with configurable permission matrices
- Organization & Malet Management โ Frontend management UI for Organizations, activity feeds, and billing
- Developer Platform Webhooks โ Real-time event notifications for commerce and product lifecycle integrations
- SIEM Event Streaming โ Stream Organization Audit Trail events to external SIEM platforms via HMAC-signed webhooks
- Subscription & Billing โ Subscription tier limits that constrain Organization membership counts and Malet creation