Files
zeekay dee0d76af8 fix: superuser token fallback in external-only auth mode + scrub PB references
- loadAuthToken now falls back to local validation for _superusers tokens
  when JWKS validation fails in external-only mode. Non-superuser local
  tokens are still rejected. Fixes seeding with IAM enabled.
- Scrub PocketBase references from docs, SDK, and UI config
2026-03-02 15:28:53 -07:00

983 lines
29 KiB
Go

// Package platform implements a multi-org platform plugin for Hanzo Base.
//
// Each org gets isolated collections with prefix-based namespacing.
// Authentication is handled via Hanzo IAM (hanzo.id) OAuth2 and secrets via
// Hanzo KMS (kms.hanzo.ai) Universal Auth.
//
// Example:
//
// platform.MustRegister(app, platform.PlatformConfig{
// IAMEndpoint: "https://hanzo.id",
// KMSEndpoint: "https://kms.hanzo.ai",
// IAMClientID: "my-client-id",
// IAMClientSecret: "my-client-secret",
// })
package platform
import (
"fmt"
"log/slog"
"net/http"
"strings"
"github.com/hanzoai/base/apis"
"github.com/hanzoai/base/core"
"github.com/hanzoai/base/tools/hook"
"github.com/hanzoai/base/tools/router"
)
const (
// System collection names.
collectionOrgs = "_orgs"
collectionOrgMembers = "_org_members"
// Header for org context scoping.
// Member roles.
RoleOwner = "owner"
RoleAdmin = "admin"
RoleMember = "member"
RoleViewer = "viewer"
)
// PlatformConfig defines the configuration for the platform plugin.
type PlatformConfig struct {
// IAMEndpoint is the base URL for Hanzo IAM (default: "https://hanzo.id").
IAMEndpoint string
// KMSEndpoint is the base URL for Hanzo KMS (default: "https://kms.hanzo.ai").
KMSEndpoint string
// IAMClientID is the OAuth2 client ID for IAM authentication.
IAMClientID string
// IAMClientSecret is the OAuth2 client secret for IAM authentication.
IAMClientSecret string
// IAMOrg is the IAM organization identifier (optional, used by auth proxy).
IAMOrg string
// IAMApp is the IAM application identifier (optional, used by auth proxy).
IAMApp string
// ComplianceEndpoint is the base URL for Lux Compliance service (optional).
// If set, enables KYC/AML screening and payment compliance for orgs.
ComplianceEndpoint string
// ComplianceAPIKey is the API key for the compliance service.
ComplianceAPIKey string
// OrgIsolation controls how org data is physically separated.
// "prefix" — (default) t_{slug}_ prefixed collections in shared DB
// "sqlite" — separate encrypted SQLite file per org in DataDir/orgs/
// "cloudsql" — separate PostgreSQL database per org (requires cloudsql plugin)
//
// For "sqlite" mode, each org gets its own database file at:
// {DataDir}/orgs/{slug}/data.db
// The file can be independently encrypted, backed up, and deleted.
// PII is physically isolated — zero data commingling.
OrgIsolation string
// OrgEncryptionKey is the master key for deriving per-org DEKs.
// Used for both SQLite encryption AND S3 SSE-C key derivation.
// Each org gets: HMAC-SHA256(masterKey, orgSlug)
// Each user gets: HMAC-SHA256(masterKey, orgSlug:userId)
// If empty, encryption is disabled (dev mode).
OrgEncryptionKey string
// OrgStorageEndpoint is the S3-compatible storage endpoint for per-org
// object storage (e.g., "s3.hanzo.space" or "s3.hanzo.ai").
// Each org and user gets isolated prefixes with SSE-C encryption.
// If empty, no per-org S3 storage is provisioned.
OrgStorageEndpoint string
// OrgStorageBucket is the root S3 bucket name (default: "orgs").
OrgStorageBucket string
// DefaultTemplates defines collection schemas cloned per org on creation.
// If nil, no default org collections are created.
DefaultTemplates []CollectionTemplate
}
// MustRegister registers the platform plugin to the provided app instance
// and panics if it fails.
func MustRegister(app core.App, config PlatformConfig) {
if err := Register(app, config); err != nil {
panic(err)
}
}
// Register registers the platform plugin to the provided app instance.
func Register(app core.App, config PlatformConfig) error {
if config.IAMEndpoint == "" || config.IAMEndpoint == "disabled" || config.IAMEndpoint == "none" {
// When IAM is explicitly disabled, skip external auth entirely.
// Base superuser tokens will still work for _superusers collection.
// All other collections fall back to Base's native auth rules.
config.IAMEndpoint = ""
}
if config.KMSEndpoint == "" {
config.KMSEndpoint = "https://kms.hanzo.ai"
}
kmsClient := NewKMSClient(config.KMSEndpoint, "")
p := &plugin{
app: app,
config: config,
iam: NewIAMClient(config.IAMEndpoint),
compliance: NewComplianceClient(config.ComplianceEndpoint, config.ComplianceAPIKey),
org: &OrgService{app: app, kms: kmsClient, config: config},
orgDB: NewOrgDB(app, config.OrgEncryptionKey),
dbPool: NewDBPoolManager(DBPoolConfig{
MaxPools: 256,
ReadConns: 4,
}),
}
// Bootstrap: ensure system collections exist.
app.OnBootstrap().BindFunc(func(e *core.BootstrapEvent) error {
if err := e.Next(); err != nil {
return err
}
return p.ensureSystemCollections()
})
// Configure the external identity provider as the exclusive auth source.
// All Base routes accept OIDC/JWKS tokens; built-in auth endpoints
// (password, OTP, email-change, etc.) are disabled for non-superuser
// collections.
if config.IAMEndpoint != "" {
jwksURL := strings.TrimRight(config.IAMEndpoint, "/") + "/.well-known/jwks"
app.Store().Set(apis.StoreKeyJWKSURL, jwksURL)
app.Store().Set(apis.StoreKeyExternalAuthOnly, true)
app.Logger().Info("platform: external auth enabled — built-in auth disabled for non-superuser collections",
slog.String("jwksURL", jwksURL),
slog.String("authEndpoint", config.IAMEndpoint),
)
} else {
app.Logger().Warn("platform: IAM disabled — Base superuser tokens accepted for all collections")
}
// Serve: register API routes, identity header middleware, and org-scoping.
app.OnServe().BindFunc(func(e *core.ServeEvent) error {
// Expose OrgService in app store for Goja JS access.
app.Store().Set("org", p.org)
// API key middleware: resolve hk-/pk-/sk- keys via IAM.
// Runs after loadAuthToken — if JWKS didn't set re.Auth (because
// the token isn't a JWT), try resolving it as an IAM API key.
// This gives every Base app native support for IAM-managed keys.
e.Router.Bind(&hook.Handler[*core.RequestEvent]{
Id: "platformAPIKeyAuth",
Priority: apis.DefaultLoadAuthTokenMiddlewarePriority + 1,
Func: func(re *core.RequestEvent) error {
// Skip if already authenticated via JWKS/JWT
if re.Auth != nil {
return re.Next()
}
// Extract Bearer token
auth := re.Request.Header.Get("Authorization")
token := ""
if len(auth) > 7 && auth[:7] == "Bearer " {
token = auth[7:]
}
// Also check query param (for /v1/config?key=pk-xxx)
if token == "" {
token = re.Request.URL.Query().Get("key")
}
if token == "" || !IsAPIKey(token) {
return re.Next()
}
// Resolve key via IAM
user, err := p.iam.ResolveAPIKey(token)
if err != nil {
app.Logger().Debug("platform: API key resolution failed",
"error", err, "prefix", token[:6]+"...")
return re.Next() // fail-open to next middleware
}
// Set identity context from resolved key
re.Set("authSub", user.ID)
re.Set("authName", user.Name)
re.Set("authEmail", user.Email)
if len(user.OrgIDs) > 0 {
re.Set("authOwner", user.OrgIDs[0])
}
// Store key type for permission checks downstream
if IsPublishableKey(token) {
re.Set("authKeyType", "publishable") // read-only
} else {
re.Set("authKeyType", "secret") // full access
}
return re.Next()
},
})
// Global middleware: set identity headers from authenticated user or API key.
// Runs after both loadAuthToken and platformAPIKeyAuth.
e.Router.Bind(&hook.Handler[*core.RequestEvent]{
Id: "platformIdentityHeaders",
Priority: apis.DefaultLoadAuthTokenMiddlewarePriority + 2,
Func: func(re *core.RequestEvent) error {
if re.Auth != nil {
userId := re.Auth.Id
email := re.Auth.GetString("email")
orgId := re.Auth.GetString("org_id")
if orgId == "" {
if v, _ := re.Get("authOwner").(string); v != "" {
orgId = v
}
}
re.Request.Header.Set("X-User-Id", userId)
re.Request.Header.Set("X-Org-Id", orgId)
re.Request.Header.Set("X-User-Email", email)
} else if sub, _ := re.Get("authSub").(string); sub != "" {
// API key auth (no re.Auth record, but identity resolved via IAM)
re.Request.Header.Set("X-User-Id", sub)
if orgId, _ := re.Get("authOwner").(string); orgId != "" {
re.Request.Header.Set("X-Org-Id", orgId)
}
if email, _ := re.Get("authEmail").(string); email != "" {
re.Request.Header.Set("X-User-Email", email)
}
}
// Preserve gateway-injected identity headers if Base auth didn't resolve.
// The gateway validates the JWT and sets X-User-Id from the sub claim.
// This is the standard path for IAM-authenticated requests.
// Do NOT clear headers that the gateway already set.
return re.Next()
},
})
// Publishable key enforcement: pk- keys are read-only.
// Blocks non-GET methods for publishable keys. Secret keys and
// JWTs have no method restrictions.
//
// Priority +3: runs after identity headers are set, before route handlers.
//
// Scope rules:
// pk- → GET only (read market data, config, public info)
// sk- → all methods (create orders, manage accounts, admin)
// hk- → all methods (IAM service key, legacy compat)
// JWT → all methods (user session via IAM OIDC)
e.Router.Bind(&hook.Handler[*core.RequestEvent]{
Id: "platformKeyTypeEnforcement",
Priority: apis.DefaultLoadAuthTokenMiddlewarePriority + 3,
Func: func(re *core.RequestEvent) error {
keyType, _ := re.Get("authKeyType").(string)
if keyType != "publishable" {
return re.Next() // JWT, sk-, hk-, or unauthenticated — no restriction
}
method := re.Request.Method
if method == http.MethodGet || method == http.MethodHead || method == http.MethodOptions {
return re.Next() // reads allowed
}
// pk- key trying to write — block it
return re.JSON(http.StatusForbidden, map[string]any{
"error": "publishable keys are read-only",
"message": "Use a secret key (sk-) or JWT for write operations.",
"code": "key_read_only",
})
},
})
// Per-request org/user database resolution.
// After identity is established, load the SQLite pool for the org/user.
// Services are fully stateless — any instance serves any org/user.
if config.OrgIsolation == "sqlite" {
e.Router.Bind(&hook.Handler[*core.RequestEvent]{
Id: "platformOrgDBResolver",
Priority: apis.DefaultLoadAuthTokenMiddlewarePriority + 4,
Func: func(re *core.RequestEvent) error {
orgId := re.Request.Header.Get("X-Org-Id")
userId := re.Request.Header.Get("X-User-Id")
if orgId == "" {
return re.Next() // no org context — use shared platform DB
}
// Resolve or auto-provision org database
orgDBPath, exists := p.orgDB.GetOrgDBPath(orgId)
if !exists {
var err error
orgDBPath, err = p.orgDB.ProvisionOrg(orgId)
if err != nil {
app.Logger().Error("platform: failed to provision org db",
"orgId", orgId, "error", err)
return re.Next()
}
}
orgPool, err := p.dbPool.Get(orgDBPath)
if err != nil {
app.Logger().Error("platform: failed to open org db pool",
"orgId", orgId, "path", orgDBPath, "error", err)
return re.Next()
}
re.Set("orgDB", orgPool)
re.Set("orgDBPath", orgDBPath)
defer orgPool.Release()
// Per-user database (only if user is authenticated)
if userId != "" {
userDBPath, exists := p.orgDB.GetUserDBPath(orgId, userId)
if !exists {
userDBPath, _ = p.orgDB.ProvisionUser(orgId, userId)
}
if userDBPath != "" {
userPool, err := p.dbPool.Get(userDBPath)
if err == nil {
re.Set("userDB", userPool)
re.Set("userDBPath", userDBPath)
defer userPool.Release()
}
}
}
return re.Next()
},
})
app.Logger().Info("platform: per-org SQLite isolation enabled",
"dataDir", app.DataDir(),
"maxPools", 256,
)
}
p.registerRoutes(e.Router)
p.registerAuthRoutes(e.Router)
p.registerOrgRoutes(e.Router)
return e.Next()
})
return nil
}
type plugin struct {
app core.App
config PlatformConfig
iam *IAMClient
compliance *ComplianceClient
org *OrgService
orgDB *OrgDB
dbPool *DBPoolManager
}
// --------------------------------------------------------------------------
// Bootstrap: system collections
// --------------------------------------------------------------------------
func (p *plugin) ensureSystemCollections() error {
if err := p.ensureOrgsCollection(); err != nil {
return fmt.Errorf("platform: ensure _orgs: %w", err)
}
if err := p.ensureMembersCollection(); err != nil {
return fmt.Errorf("platform: ensure _org_members: %w", err)
}
if err := p.ensureOrgConfigsCollection(); err != nil {
return fmt.Errorf("platform: ensure %s: %w", collectionOrgConfigs, err)
}
if err := p.ensureOrgCustomersCollection(); err != nil {
return fmt.Errorf("platform: ensure %s: %w", collectionOrgCustomers, err)
}
return nil
}
func (p *plugin) ensureOrgsCollection() error {
_, err := p.app.FindCollectionByNameOrId(collectionOrgs)
if err == nil {
return nil // already exists
}
c := core.NewBaseCollection(collectionOrgs)
c.System = true
c.Fields.Add(
&core.TextField{Name: "name", Required: true, Min: 1, Max: 100},
&core.TextField{Name: "slug", Required: true, Min: 1, Max: 50},
&core.TextField{Name: "ownerId", Required: true},
&core.TextField{Name: "iamOrgId"},
&core.TextField{Name: "kmsProjectId"},
&core.AutodateField{Name: "created", OnCreate: true},
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
)
p.app.Logger().Info("creating platform system collection", slog.String("name", collectionOrgs))
return p.app.Save(c)
}
func (p *plugin) ensureMembersCollection() error {
_, err := p.app.FindCollectionByNameOrId(collectionOrgMembers)
if err == nil {
return nil
}
c := core.NewBaseCollection(collectionOrgMembers)
c.System = true
c.Fields.Add(
&core.TextField{Name: "orgId", Required: true},
&core.TextField{Name: "userId", Required: true},
&core.SelectField{
Name: "role",
Required: true,
MaxSelect: 1,
Values: []string{RoleOwner, RoleAdmin, RoleMember, RoleViewer},
},
&core.AutodateField{Name: "created", OnCreate: true},
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
)
p.app.Logger().Info("creating platform system collection", slog.String("name", collectionOrgMembers))
return p.app.Save(c)
}
// --------------------------------------------------------------------------
// Route registration
// --------------------------------------------------------------------------
func (p *plugin) registerRoutes(r *router.Router[*core.RequestEvent]) {
api := r.Group("/api/platform")
// Org CRUD
api.POST("/orgs", p.handleCreateOrg)
api.GET("/orgs", p.handleListOrgs)
api.GET("/orgs/{id}", p.handleGetOrg)
api.DELETE("/orgs/{id}", p.handleDeleteOrg)
// Member management
api.POST("/orgs/{id}/members", p.handleInviteMember)
// Compliance (optional — only registered if endpoint configured)
if p.compliance != nil && p.compliance.Enabled() {
api.POST("/compliance/application", p.handleCreateComplianceApp)
api.POST("/compliance/kyc/{applicationId}", p.handleInitiateKYC)
api.GET("/compliance/kyc/{applicationId}", p.handleGetKYCStatus)
api.POST("/compliance/screen", p.handleScreenIndividual)
api.POST("/compliance/payment/validate", p.handleValidatePayment)
}
}
// --------------------------------------------------------------------------
// Route handlers
// --------------------------------------------------------------------------
func (p *plugin) handleCreateOrg(e *core.RequestEvent) error {
user, err := p.requireAuth(e)
if err != nil {
return err
}
var body struct {
Name string `json:"name"`
Slug string `json:"slug"`
}
if err := e.BindBody(&body); err != nil {
return e.BadRequestError("Invalid request body.", err)
}
body.Name = strings.TrimSpace(body.Name)
body.Slug = strings.TrimSpace(body.Slug)
if body.Name == "" || body.Slug == "" {
return e.BadRequestError("name and slug are required", nil)
}
if !isValidSlug(body.Slug) {
return e.BadRequestError("slug must be lowercase alphanumeric with hyphens, no leading/trailing hyphen", nil)
}
// Check slug uniqueness.
existing, _ := p.app.FindFirstRecordByData(collectionOrgs, "slug", body.Slug)
if existing != nil {
return e.BadRequestError("slug already in use", nil)
}
// Create KMS project for org (best-effort).
var kmsProjectId string
if p.config.IAMClientID != "" && p.config.KMSEndpoint != "" {
pid, kmsErr := CreateOrgProject(body.Slug, p.config)
if kmsErr != nil {
p.app.Logger().Warn("failed to create KMS project",
slog.String("slug", body.Slug),
slog.String("error", kmsErr.Error()),
)
} else {
kmsProjectId = pid
}
}
// Create org record.
col, err := p.app.FindCollectionByNameOrId(collectionOrgs)
if err != nil {
return e.InternalServerError("_orgs collection not found", err)
}
org := core.NewRecord(col)
org.Set("name", body.Name)
org.Set("slug", body.Slug)
org.Set("ownerId", user.ID)
org.Set("iamOrgId", "")
org.Set("kmsProjectId", kmsProjectId)
if err := p.app.Save(org); err != nil {
return e.InternalServerError("failed to create org", err)
}
// Create owner membership.
if err := addMember(p.app, org.Id, user.ID, RoleOwner); err != nil {
_ = p.app.Delete(org)
return e.InternalServerError("failed to create owner membership", err)
}
// Create org collections from templates.
if len(p.config.DefaultTemplates) > 0 {
if err := CreateOrgCollections(p.app, body.Slug, p.config.DefaultTemplates); err != nil {
p.app.Logger().Warn("failed to create org collections",
slog.String("slug", body.Slug),
slog.String("error", err.Error()),
)
}
}
return e.JSON(http.StatusCreated, map[string]any{
"id": org.Id,
"name": body.Name,
"slug": body.Slug,
"kmsProjectId": kmsProjectId,
})
}
func (p *plugin) handleListOrgs(e *core.RequestEvent) error {
user, err := p.requireAuth(e)
if err != nil {
return err
}
// Find all memberships for this user.
members, err := p.app.FindRecordsByFilter(
collectionOrgMembers,
"userId = {:userId}",
"",
0, 0,
map[string]any{"userId": user.ID},
)
if err != nil {
return e.InternalServerError("failed to query memberships", err)
}
if len(members) == 0 {
return e.JSON(http.StatusOK, []any{})
}
orgIds := make([]string, 0, len(members))
rolesByOrg := make(map[string]string, len(members))
for _, m := range members {
oid := m.GetString("orgId")
orgIds = append(orgIds, oid)
rolesByOrg[oid] = m.GetString("role")
}
orgs, err := p.app.FindRecordsByIds(collectionOrgs, orgIds)
if err != nil {
return e.InternalServerError("failed to fetch orgs", err)
}
result := make([]map[string]any, 0, len(orgs))
for _, o := range orgs {
result = append(result, map[string]any{
"id": o.Id,
"name": o.GetString("name"),
"slug": o.GetString("slug"),
"role": rolesByOrg[o.Id],
})
}
return e.JSON(http.StatusOK, result)
}
func (p *plugin) handleGetOrg(e *core.RequestEvent) error {
user, err := p.requireAuth(e)
if err != nil {
return err
}
orgId := e.Request.PathValue("id")
if orgId == "" {
return e.BadRequestError("missing org id", nil)
}
if !checkAccess(p.app, orgId, user.ID, "read") {
return e.ForbiddenError("access denied", nil)
}
org, err := p.app.FindRecordById(collectionOrgs, orgId)
if err != nil {
return e.NotFoundError("org not found", err)
}
return e.JSON(http.StatusOK, map[string]any{
"id": org.Id,
"name": org.GetString("name"),
"slug": org.GetString("slug"),
"ownerId": org.GetString("ownerId"),
"iamOrgId": org.GetString("iamOrgId"),
"kmsProjectId": org.GetString("kmsProjectId"),
})
}
func (p *plugin) handleDeleteOrg(e *core.RequestEvent) error {
user, err := p.requireAuth(e)
if err != nil {
return err
}
orgId := e.Request.PathValue("id")
if orgId == "" {
return e.BadRequestError("missing org id", nil)
}
org, err := p.app.FindRecordById(collectionOrgs, orgId)
if err != nil {
return e.NotFoundError("org not found", err)
}
// Owner only.
if org.GetString("ownerId") != user.ID {
return e.ForbiddenError("only the owner can delete an org", nil)
}
slug := org.GetString("slug")
// Delete all org-prefixed collections.
prefix := OrgPrefix(slug)
allCollections, err := p.app.FindAllCollections()
if err == nil {
for _, col := range allCollections {
if strings.HasPrefix(col.Name, prefix) {
if delErr := p.app.Delete(col); delErr != nil {
p.app.Logger().Warn("failed to delete org collection",
slog.String("collection", col.Name),
slog.String("error", delErr.Error()),
)
}
}
}
}
// Delete all memberships.
memberships, _ := p.app.FindRecordsByFilter(
collectionOrgMembers,
"orgId = {:orgId}",
"", 0, 0,
map[string]any{"orgId": orgId},
)
for _, m := range memberships {
_ = p.app.Delete(m)
}
// Delete org record.
if err := p.app.Delete(org); err != nil {
return e.InternalServerError("failed to delete org", err)
}
return e.JSON(http.StatusOK, map[string]bool{"deleted": true})
}
func (p *plugin) handleInviteMember(e *core.RequestEvent) error {
user, err := p.requireAuth(e)
if err != nil {
return err
}
orgId := e.Request.PathValue("id")
if orgId == "" {
return e.BadRequestError("missing org id", nil)
}
// Require admin or owner.
if !checkAccess(p.app, orgId, user.ID, "admin") {
return e.ForbiddenError("only owners and admins can invite members", nil)
}
var body struct {
UserID string `json:"userId"`
Role string `json:"role"`
}
if err := e.BindBody(&body); err != nil {
return e.BadRequestError("invalid request body", err)
}
body.UserID = strings.TrimSpace(body.UserID)
body.Role = strings.TrimSpace(body.Role)
if body.UserID == "" {
return e.BadRequestError("userId is required", nil)
}
// Validate role.
validRoles := map[string]bool{RoleAdmin: true, RoleMember: true, RoleViewer: true}
if !validRoles[body.Role] {
body.Role = RoleMember
}
// Check if already a member.
existing, _ := findMembership(p.app, body.UserID, orgId)
if existing != nil {
return e.BadRequestError("user is already a member of this org", nil)
}
if err := addMember(p.app, orgId, body.UserID, body.Role); err != nil {
return e.InternalServerError("failed to add member", err)
}
return e.JSON(http.StatusCreated, map[string]any{
"orgId": orgId,
"userId": body.UserID,
"role": body.Role,
})
}
// --------------------------------------------------------------------------
// Auth helper
// --------------------------------------------------------------------------
// requireAuth extracts and validates the Bearer token. Checks Base built-in
// auth first, then falls back to IAM token validation.
func (p *plugin) requireAuth(e *core.RequestEvent) (*IAMUser, error) {
// If Base already authenticated the user, use that.
if e.Auth != nil {
return &IAMUser{
ID: e.Auth.Id,
Email: e.Auth.GetString("email"),
Name: e.Auth.GetString("name"),
}, nil
}
// Extract bearer token.
authHeader := e.Request.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
return nil, e.UnauthorizedError("missing or invalid authorization", nil)
}
token := strings.TrimPrefix(authHeader, "Bearer ")
user, err := p.iam.ValidateToken(token)
if err != nil {
return nil, e.UnauthorizedError("invalid or expired token", err)
}
return user, nil
}
// --------------------------------------------------------------------------
// Membership helpers
// --------------------------------------------------------------------------
func findMembership(app core.App, userId, orgId string) (*core.Record, error) {
records, err := app.FindRecordsByFilter(
collectionOrgMembers,
"userId = {:userId} && orgId = {:orgId}",
"",
1, 0,
map[string]any{"userId": userId, "orgId": orgId},
)
if err != nil || len(records) == 0 {
return nil, fmt.Errorf("membership not found")
}
return records[0], nil
}
func addMember(app core.App, orgId, userId, role string) error {
col, err := app.FindCollectionByNameOrId(collectionOrgMembers)
if err != nil {
return fmt.Errorf("_org_members collection not found: %w", err)
}
record := core.NewRecord(col)
record.Set("orgId", orgId)
record.Set("userId", userId)
record.Set("role", role)
return app.Save(record)
}
// checkAccess verifies that userId has at least the required permission level
// for the given org.
//
// Hierarchy: owner(4) > admin(3) > member(2) > viewer/read(1).
func checkAccess(app core.App, orgId, userId, permission string) bool {
m, err := findMembership(app, userId, orgId)
if err != nil {
return false
}
return roleHasPermission(m.GetString("role"), permission)
}
func roleHasPermission(role, permission string) bool {
levels := map[string]int{
RoleViewer: 1, RoleMember: 2, RoleAdmin: 3, RoleOwner: 4,
}
required := map[string]int{
"read": 1, "member": 2, "admin": 3, "owner": 4,
}
roleLevel, ok := levels[role]
if !ok {
return false
}
requiredLevel, ok := required[permission]
if !ok {
return false
}
return roleLevel >= requiredLevel
}
// --------------------------------------------------------------------------
// Compliance handlers
// --------------------------------------------------------------------------
func (p *plugin) handleCreateComplianceApp(e *core.RequestEvent) error {
user, err := p.requireAuth(e)
if err != nil {
return err
}
var body struct {
GivenName string `json:"given_name"`
FamilyName string `json:"family_name"`
Country string `json:"country"`
}
if err := e.BindBody(&body); err != nil {
return e.BadRequestError("invalid request body", err)
}
appID, err := p.compliance.CreateApplication(body.GivenName, body.FamilyName, user.Email, body.Country)
if err != nil {
return e.InternalServerError("compliance: create application failed", err)
}
return e.JSON(http.StatusCreated, map[string]string{
"application_id": appID,
"user_id": user.ID,
})
}
func (p *plugin) handleInitiateKYC(e *core.RequestEvent) error {
if _, err := p.requireAuth(e); err != nil {
return err
}
applicationID := e.Request.PathValue("applicationId")
if applicationID == "" {
return e.BadRequestError("missing applicationId", nil)
}
var body struct {
Provider string `json:"provider,omitempty"`
}
e.BindBody(&body)
verID, redirectURL, err := p.compliance.InitiateKYC(applicationID, body.Provider)
if err != nil {
return e.InternalServerError("compliance: initiate KYC failed", err)
}
return e.JSON(http.StatusOK, map[string]string{
"verification_id": verID,
"redirect_url": redirectURL,
})
}
func (p *plugin) handleGetKYCStatus(e *core.RequestEvent) error {
if _, err := p.requireAuth(e); err != nil {
return err
}
applicationID := e.Request.PathValue("applicationId")
if applicationID == "" {
return e.BadRequestError("missing applicationId", nil)
}
status, err := p.compliance.GetKYCStatus(applicationID)
if err != nil {
return e.InternalServerError("compliance: get KYC status failed", err)
}
return e.JSON(http.StatusOK, status)
}
func (p *plugin) handleScreenIndividual(e *core.RequestEvent) error {
if _, err := p.requireAuth(e); err != nil {
return err
}
var body struct {
GivenName string `json:"given_name"`
FamilyName string `json:"family_name"`
Country string `json:"country"`
}
if err := e.BindBody(&body); err != nil {
return e.BadRequestError("invalid request body", err)
}
result, err := p.compliance.ScreenIndividual(body.GivenName, body.FamilyName, body.Country)
if err != nil {
return e.InternalServerError("compliance: screening failed", err)
}
return e.JSON(http.StatusOK, result)
}
func (p *plugin) handleValidatePayment(e *core.RequestEvent) error {
if _, err := p.requireAuth(e); err != nil {
return err
}
var body struct {
FromAccountID string `json:"from_account_id"`
ToAccountID string `json:"to_account_id"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
Jurisdiction string `json:"jurisdiction"`
}
if err := e.BindBody(&body); err != nil {
return e.BadRequestError("invalid request body", err)
}
approved, reason, err := p.compliance.ValidatePayment(
body.FromAccountID, body.ToAccountID, body.Amount, body.Currency, body.Jurisdiction,
)
if err != nil {
return e.InternalServerError("compliance: payment validation failed", err)
}
return e.JSON(http.StatusOK, map[string]interface{}{
"approved": approved,
"reason": reason,
})
}
// --------------------------------------------------------------------------
// Slug validation
// --------------------------------------------------------------------------
// isValidSlug checks that s contains only lowercase alphanumeric chars and
// hyphens, is non-empty, and does not start/end with a hyphen.
func isValidSlug(s string) bool {
if s == "" || s[0] == '-' || s[len(s)-1] == '-' {
return false
}
for _, ch := range s {
if !((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-') {
return false
}
}
return true
}