Files
zeekayandHanzo Dev 10f67d734c Org Bases open under the KMS master key or not at all
The base binary passes IAM_ORGANIZATION as IAMOrg, and plugins/org
reads base/MASTER_KEY_B64 beneath it at Register: 32 bytes, base64.
With KMS configured, Register fails when no org is set, when KMS does
not answer, or when the key is not 32 bytes. No key stored leaves org
Bases unencrypted and logs it; a 404 from the KMS HTTP door reads as a
missing secret.

An encrypted Base needs SQLite to encrypt pages as it writes them, which
only a linked SQLCipher codec does, so Register refuses a master key on a
build without one. Under a key, the connector refuses an org Base file
that is plaintext.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-11 13:58:52 -07:00

805 lines
30 KiB
Go

// Package org gives one Base process a Base per org.
//
// An org's Base is a Base of its own under {DataDir}/orgs/{org}/, opened the
// first time a request arrives carrying that org. Isolation is physical: a
// different org is a different file, so there is no query that can read across
// two. The file is opened under that org's own key — see [encryptedConnect] —
// so one org's data is unreadable with another's, and a deployment that
// configures no master key opens plaintext and says so rather than pretending
// otherwise.
//
// Orgs and members are IAM's, read off the validated token. This package never
// writes them — a local copy is a second answer to "who is in this org", and
// the one a request arrives on wins.
//
// It publishes /v1/bases: which Bases the caller can reach, and what state each
// is in. There is no create verb; using an org opens its Base.
//
// org.MustRegister(app, org.Config{
// IAMEndpoint: "https://hanzo.id",
// KMSEndpoint: "zap.kms.svc.cluster.local:9999",
// IAMClientID: "my-client-id",
// IAMClientSecret: "my-client-secret",
// })
package org
import (
"errors"
"fmt"
"net/http"
"os"
"slices"
"strings"
"github.com/hanzoai/base/apis"
"github.com/hanzoai/base/core"
"github.com/hanzoai/base/tools/hook"
"github.com/hanzoai/base/tools/router"
"github.com/hanzoai/sqlite"
)
// apiKeyAuthId names the middleware that resolves an IAM key, so that a route
// which must not be authenticated by Base can say which door it is closing.
const apiKeyAuthId = "platformAPIKeyAuth"
// Config defines the configuration for the platform plugin.
type Config struct {
// IAMEndpoint is the base URL for Hanzo IAM (default: "https://hanzo.id").
//
// It names the BRAND, so minting a token always addresses it: IAM derives
// the issuer from the request host, and a relying party that discovered
// through one brand refuses a token issued by another.
IAMEndpoint string
// IAMAddress is where the service answers, when that is not where the brand
// lives. Reading takes it — validating a token, resolving a key — because
// none of those answers depend on which brand was addressed.
//
// A brand's public origin leaves the cluster and comes back to a pod one
// hop away. "iam.hanzo.svc.cluster.local:9653" is the pod, over ZAP, which
// is what a bare address means. Empty means read the brand's origin.
IAMAddress string
// KMSEndpoint is the KMS ZAP address — "host:port", "zap://host:port" or
// "zap+mdns://_kms._tcp" (default: "zap.kms.svc.cluster.local:9999").
// An http(s) URL is rejected at Register: Base speaks native ZAP to KMS.
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 org this Base belongs to. The master key is read from
// KMS beneath it, so it is required when KMSEndpoint is set.
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
// 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 Config) {
if err := Register(app, config); err != nil {
panic(err)
}
}
// Register registers the platform plugin to the provided app instance.
//
// Hanzo Base is a pure IAM client — it never hosts identity. IAM must be
// reachable at boot via IAM_ENDPOINT (a hanzo.id base, or an in-process
// iam.Embed() served by the fused daemon). Base validates IAM JWTs against
// that endpoint's JWKS; there is no local password / OTP / MFA surface.
func Register(app core.App, config Config) error {
if config.IAMEndpoint == "" || config.IAMEndpoint == "disabled" || config.IAMEndpoint == "none" {
return fmt.Errorf(
"platform: IAM_ENDPOINT is required — Hanzo Base is a pure IAM " +
"client and does not host identity. Set IAM_ENDPOINT to a " +
"Hanzo IAM instance (e.g. https://hanzo.id) or an in-process " +
"iam.Embed() served by the fused daemon")
}
kmsClient, err := NewKMSClient(config.KMSEndpoint, config.IAMClientID, config.IAMClientSecret)
if err != nil {
return err
}
// The master key per-principal DEKs derive from, read from KMS beneath
// IAMOrg. Without one, which is what `make dev` runs, org Bases are not
// encrypted and the log says so. With one, SQLite has to encrypt pages as it
// writes them, which only a linked SQLCipher codec does.
masterKey, err := kmsClient.masterKey(config.IAMOrg)
if err != nil {
return err
}
if masterKey != "" && !sqlite.CodecLinked() {
return errors.New("platform: a master key is configured and this build has no SQLCipher codec " +
"linked; the pure-Go codec gives each handle a private copy of a file, which a Base cannot " +
"run on — build with SQLCipher (github.com/hanzoai/sqlite)")
}
if masterKey == "" {
app.Logger().Warn("org Bases are not encrypted: no master key", "coordinate", masterKeyPath)
}
// The service's OWN IAM application credentials, installed where the client is
// built. Every server-to-server read presents them — resolving a key to its
// holder, reading a user by address — so a caller cannot reach IAM without one
// and read whatever an anonymous request happens to answer with.
iamClient := NewIAMClient(readAddress(config))
iamClient.SetAdminCreds(AdminCreds{
ClientID: config.IAMClientID,
ClientSecret: config.IAMClientSecret,
Owner: config.IAMOrg,
})
p := &plugin{
app: app,
config: config,
iam: iamClient,
compliance: NewComplianceClient(config.ComplianceEndpoint, config.ComplianceAPIKey),
org: &OrgService{app: app, kms: kmsClient, config: config},
orgDB: NewOrgDB(app, masterKey),
jwksURL: strings.TrimRight(config.IAMEndpoint, "/") + "/v1/iam/.well-known/jwks",
}
p.bases = newBases(p)
// Bootstrap: ensure platform system collections exist.
app.OnBootstrap().BindFunc(func(e *core.BootstrapEvent) error {
if err := e.Next(); err != nil {
return err
}
return p.ensureSystemCollections()
})
// Terminate: release the long-lived KMS connection and the Bases this
// process opened.
app.OnTerminate().BindFunc(func(e *core.TerminateEvent) error {
kmsClient.Close()
p.bases.close()
return e.Next()
})
// Which Base serves an org. Reading it is how a request reaches the right
// one; without it every read lands on the process's own Base, which is what
// used to happen.
app.Store().Set(apis.StoreKeyBases, apis.Bases(p.bases.base))
p.declare(app)
app.Logger().Info("platform: IAM is the only auth source",
"jwksURL", p.jwksURL,
"authEndpoint", config.IAMEndpoint,
)
// 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: apiKeyAuthId,
Priority: apis.DefaultLoadAuthTokenMiddlewarePriority + 1,
Func: func(re *core.RequestEvent) error {
// Skip if already authenticated via JWKS/JWT
if re.Auth != nil {
return re.Next()
}
token := apis.Credential(re.Request)
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
}
// A key acts in the org IAM issued it under. Where the request
// also names one, the key must already carry it — the same rule
// a token gets, stated separately because a key is a machine and
// holds none of the cross-tenant scope a platform operator does.
org := ""
if len(user.OrgIDs) > 0 {
org = user.OrgIDs[0]
}
if stated := apis.StatedOrg(re); stated != "" {
if !slices.Contains(user.OrgIDs, stated) {
return re.ForbiddenError("The key does not carry the requested organization.", nil)
}
org = stated
}
if org == "" {
return re.ForbiddenError("The key carries no organization.", nil)
}
base, err := p.bases.base(org)
if err != nil {
return re.InternalServerError("Failed to open the Base for this organization.", err)
}
re.App = base
// Set identity context from resolved key
re.Set(apis.RequestEventKeySub, user.ID)
re.Set(apis.RequestEventKeyName, user.Name)
re.Set("authEmail", user.Email)
re.Set("authOwner", org)
re.Set(apis.RequestEventKeyOrg, org)
re.Request.Header.Set("X-User-Id", user.ID)
re.Request.Header.Set("X-Org-Id", org)
re.Request.Header.Set("X-User-Email", user.Email)
// A secret key belongs to the org's own server rather than to
// any one person, so it acts for the org: it reaches every
// member's row the way an org admin's token does. A publishable
// key acts for nobody — it is in a web page — and what it
// reaches is publishableReachesOnlyPublic's answer.
if !IsPublishableKey(token) {
re.Set(apis.RequestEventKeyOrgAdmin, true)
}
return re.Next()
},
})
// What a credential is required to be to reach an address: the first
// rule holds everywhere, the three after it under /v1/bases. All four
// are bound on the ROUTER and read the path.
//
// Not bound on the group that declares those routes. A group states its
// middleware down the GROUP tree and not down the URL, so a route
// registered off the router at the identical address inherits nothing —
// and two live mechanisms register exactly that way: /v1/bases itself,
// just below, and jsvm's routerAdd, which takes a path string from an
// extension and validates none of it. The comment this replaces claimed
// that stating the rule on the subtree made a handler that forgets it
// impossible to write. It is the address that carries the data, so it
// has to be the address that carries the rule.
//
// Anonymous by construction: an anonymous middleware has no id and
// Unbind removes nothing that has no id, so no group beneath this one
// can drop the tenant boundary the way /v1/iam legitimately drops the
// doors that resolve a credential. That subtree is why the publishable
// rule reads the request for its credential rather than what those
// doors published about it.
e.Router.BindFunc(publishableReachesOnlyPublic, actsInNamedOrg, secretsBelongToTheOrg, namesItsOwnUser)
p.registerRoutes(e.Router)
p.registerOrgRoutes(e.Router)
// /v1/iam mount: transparent reverse proxy to IAM_ENDPOINT so the
// admin UI and SDKs see IAM at a stable local path.
p.registerIAMProxy(e.Router)
// /v1/idv mount: thin reverse proxy to the standalone hanzoai/idv
// service via IDV_ENDPOINT. Registered regardless of IAM mode —
// IDV is its own service domain, not a child of IAM. With
// IDV_ENDPOINT unset, /v1/idv/status returns {enabled:false}
// and the rest return 503.
p.registerIDVProxy(e.Router)
return e.Next()
})
return nil
}
type plugin struct {
app core.App
config Config
iam *IAMClient
compliance *ComplianceClient
org *OrgService
orgDB *OrgDB
bases *bases
jwksURL string
}
// declare is what this plugin states on every Base it opens, the process's own
// included.
//
// A tenant's Base has to answer the way the platform's does — same single auth
// source, same ownership stamped from the validated principal — because it is
// the same product serving a different org. Stating it in one function is what
// keeps the two from drifting; the alternative is a handler that reads a store
// key set on only one Base and silently takes the zero value on the others.
//
// What a tenant's Base must NOT get is anything that reaches past its own org.
func (p *plugin) declare(app core.App) {
// IAM is the only auth source. Every Base route validates JWTs against
// IAM's JWKS; the legacy local-password / OTP / MFA paths are unreachable
// (see apis/record_auth_*: 410 Gone with a Location pointer to the IAM
// equivalent). Base never hosts identity — it only validates.
app.Store().Set(apis.StoreKeyExternalAuthOnly, true)
app.Store().Set(apis.StoreKeyJWKSURL, p.jwksURL)
// OrgService is NOT among what a tenant's Base gets. Its methods take an
// org as an argument and check nothing, so an extension running on one
// tenant's Base could read every other tenant's credentials and customers
// by naming them — the whole boundary this package exists to draw, undone
// by a store key. It stays on the process's own Base, where an operator's
// extension runs, and it is set there by Register.
//
// Stamp owner+org on every base-collection create from the VALIDATED
// principal — never from client body/headers. A caller cannot attribute a
// record to another user or org.
app.OnRecordCreateRequest().BindFunc(stampOrgOwnership)
omitNested(app)
}
// omitNested states what a Base's backup does not carry: the stores that sit in
// its data directory and are not it.
//
// A backup quiesces one Base — its transaction, its write-ahead log checkpoint.
// An org's Base under the platform's data directory, and a user's database
// under that org's, each have their own handles and their own log, outside
// both. Copying one anyway takes it mid-write and pairs it with a log copied at
// another instant, so the archive holds a torn file and says nothing about it.
// Each is backed up where it is quiesced: on itself.
//
// One statement for every Base, rather than one per level. The platform has no
// users directory and an org has no orgs directory, so each Base names one
// thing it has and one it does not — which is cheaper than two statements that
// have to be kept in step, and the next level down cannot be the one somebody
// forgets.
//
// Both hooks, and it is the same function on each: a restore moves aside
// everything it is about to replace, so an archive that leaves a store out
// while a restore does not spare it would delete it.
//
// What the archive DOES carry, it says — see tools/archive, which writes the
// list into the zip's own comment.
func omitNested(app core.App) {
omit := func(e *core.BackupEvent) error {
e.Exclude = append(e.Exclude, orgsDirName, usersDirName)
return e.Next()
}
app.OnBackupCreate().BindFunc(omit)
app.OnBackupRestore().BindFunc(omit)
}
// --------------------------------------------------------------------------
// Bootstrap: system collections
// --------------------------------------------------------------------------
// ensureSystemCollections creates what this plugin reads and writes.
//
// Orgs and memberships are not among them. IAM owns both nouns and this package
// reads them off the validated token, so there is no _orgs or _org_members
// collection to keep in step — a local copy would be a second answer to who
// belongs to an org, and the credential the request arrived on is the one that
// answers it.
func (p *plugin) ensureSystemCollections() error {
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
}
// --------------------------------------------------------------------------
// Route registration
// --------------------------------------------------------------------------
func (p *plugin) registerRoutes(r *router.Router[*core.RequestEvent]) {
// A Base is what an org gets: its own collections, its own files, its own
// key. So the resource a client addresses is /v1/bases, and never /v1/orgs —
// the org is IAM's noun and Base does not get a second one. It is not
// /v1/platform either: that is the PaaS at platform.hanzo.ai, a different
// product, and sharing its name here is what made "where are my Bases?"
// unanswerable.
//
// ORGS AND MEMBERSHIPS ARE IAM'S. Base READS them and never writes them:
// minting an org here made a second registry with its own slug rules and its
// own uniqueness check, so two systems both believed they owned the noun and
// the one a token was issued against was not necessarily the one Base knew.
//
// IAM already answers both questions — /v1/iam/organizations owns the org, and
// a validated token carries the caller's memberships (IAMUser.OrgIDs), so the
// read needs no call at all. Create, delete and invite live at IAM.
//
// This is the collection, which names no org and so answers the membership
// question: every Base on the caller's token. Everything that names one lives
// under it and is registered together in registerOrgRoutes, where the rule for
// naming an org is stated once. Declared whole rather than on a group, so the
// collection root is /v1/bases and not /v1/bases/ — an empty leaf composes to
// the trailing slash, which is an address nobody calls.
r.GET(basesPath, p.handleListBases)
// Compliance is its own service domain, a sibling of /v1/iam — not a child
// of Base's own resource. Optional: registered only where it is configured.
if p.compliance != nil && p.compliance.Enabled() {
c := r.Group("/v1/compliance")
c.POST("/application", p.handleCreateComplianceApp)
c.POST("/kyc/{applicationId}", p.handleInitiateKYC)
c.GET("/kyc/{applicationId}", p.handleGetKYCStatus)
c.POST("/screen", p.handleScreenIndividual)
c.POST("/payment/validate", p.handleValidatePayment)
}
}
// --------------------------------------------------------------------------
// Route handlers
// --------------------------------------------------------------------------
// baseView is what a Base IS to a caller: the org it belongs to, whether one
// has been opened yet, and how big it has grown.
//
// There is no verb to create one. A Base is opened the first time a request
// arrives carrying its org, so "exists" reports what happened rather than what
// somebody remembered to provision — which is why this reports a fact from the
// filesystem and not a row from a table.
type baseView struct {
Org string `json:"org"`
Exists bool `json:"exists"`
Bytes int64 `json:"bytes,omitempty"`
}
// base reports one org's Base. An org the caller belongs to that has never been
// used answers exists:false, which is the honest answer and not an error.
func (p *plugin) base(org string) baseView {
v := baseView{Org: org}
path, ok := p.orgDB.GetOrgDBPath(org)
if !ok {
return v
}
v.Exists = true
if st, err := os.Stat(path); err == nil {
v.Bytes = st.Size()
}
return v
}
// handleListBases answers which Bases the caller can reach.
//
// The orgs come from the validated token — IAM puts the membership on it, so
// this needs no local table and no call back to IAM. The local membership rows
// this used to read were a copy that could disagree with the token a request
// actually arrived on.
func (p *plugin) handleListBases(e *core.RequestEvent) error {
user, err := p.requireAuth(e)
if err != nil {
return err
}
out := make([]baseView, 0, len(user.OrgIDs))
for _, org := range user.OrgIDs {
if org == "" {
continue
}
out = append(out, p.base(org))
}
return e.JSON(http.StatusOK, out)
}
// handleGetBase answers for one Base.
//
// Who may ask is settled on the subtree this route hangs off, beside the six
// other routes that name an org — see actsInNamedOrg. It used to say so here in
// its own words, which was the fourth statement of a rule three of its
// neighbours had forgotten to make.
func (p *plugin) handleGetBase(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, p.base(e.Request.PathValue("orgId")))
}
func (p *plugin) requireAuth(e *core.RequestEvent) (*IAMUser, error) {
// If Base already authenticated the user, use that.
//
// The memberships come off the request rather than off the record, because a
// record holds fields and membership is not one of Base's — IAM signs it onto
// the token and resolveJWKSToken puts it here. Omitting it left OrgIDs empty,
// so every Base was refused and the list of them came back empty: not "you
// have no orgs" but "nobody thought to bring them along".
if e.Auth != nil {
orgs, _ := e.Get(apis.RequestEventKeyOrgs).([]string)
return &IAMUser{
ID: e.Auth.Id,
Email: e.Auth.GetString("email"),
Name: e.Auth.GetString("name"),
OrgIDs: orgs,
}, 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
}
// --------------------------------------------------------------------------
// Compliance handlers
// --------------------------------------------------------------------------
// requireOrg is what every compliance route needs before anything else: an org
// the credential actually acts in.
//
// These routes name no org in their address, so the rules on /v1/bases do not
// reach them and they have to ask. requireAuth alone answers "is there a
// caller", which is not the question — a KYC record belongs to somebody.
func requireOrg(e *core.RequestEvent) (string, error) {
org := actingOrg(e)
if org == "" {
return "", e.ForbiddenError("The credential acts in no organization.", nil)
}
return org, nil
}
// ownsComplianceApp refuses an application the credential does not hold.
//
// The application id is the vendor's and arrives in the path, so on its own it
// is an unowned name: taking it and calling requireAuth answered "is there a
// caller" and never "is this caller's", and one tenant read another tenant's
// KYC status — provider, verification state and all — and started verifications
// against it. What makes it answerable is the row written when the application
// was created.
//
// The refusal for an application this org does not hold is 403 rather than 404,
// for the reason actsInNamedOrg gives: a 404 is an answer about the data, and
// it would separate "someone else's application" from "no such application".
func (p *plugin) ownsComplianceApp(e *core.RequestEvent, applicationID string) error {
org, err := requireOrg(e)
if err != nil {
return err
}
owner, ok := p.org.ComplianceApp(org, applicationID)
if !ok || !actsForUser(e, owner) {
return e.ForbiddenError("The credential does not hold that application.", nil)
}
return nil
}
func (p *plugin) handleCreateComplianceApp(e *core.RequestEvent) error {
user, err := p.requireAuth(e)
if err != nil {
return err
}
org, err := requireOrg(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)
}
// An application nobody is recorded as holding is one nobody can read back,
// so failing to record it fails the call rather than returning an id into
// the void.
if err := p.org.BindComplianceApp(org, caller(e), appID); err != nil {
return e.InternalServerError("compliance: record who holds the application", 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)
}
if err := p.ownsComplianceApp(e, applicationID); err != nil {
return err
}
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)
}
if err := p.ownsComplianceApp(e, applicationID); err != nil {
return err
}
status, err := p.compliance.GetKYCStatus(applicationID)
if err != nil {
return e.InternalServerError("compliance: get KYC status failed", err)
}
return e.JSON(http.StatusOK, status)
}
// handleScreenIndividual screens a name the caller supplies.
//
// There is no owner to check here — the subject is a person the org is
// considering, not a record anyone holds — so what this can be scoped to is the
// org doing the asking. Each call spends a screening on the deployment's
// vendor account, and the control for that is the rate limit and the org on the
// log line, not an ownership question that has no subject.
func (p *plugin) handleScreenIndividual(e *core.RequestEvent) error {
if _, err := p.requireAuth(e); err != nil {
return err
}
if _, err := requireOrg(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
}
if _, err := requireOrg(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
}
// stampOrgOwnership force-sets owner+org on record create from the VALIDATED
// principal (IAM JWT auth record, or resolved IAM API key) — never from the
// request body or headers, so base attribution cannot be forged. Collections
// without owner/org fields (non-base) are left untouched. An org-scoped record
// with no trusted org (anonymous, no key) is refused — this is what makes the
// publishable key mandatory even when a collection's createRule is "".
func stampOrgOwnership(e *core.RecordRequestEvent) error {
col := e.Collection
hasOwner := col.Fields.GetByName("owner") != nil
hasOrg := col.Fields.GetByName("org") != nil
if col.System || col.IsAuth() || (!hasOwner && !hasOrg) {
return e.Next()
}
// Derive identity from the validated principal ONLY.
//
// The org is the one the request acts in, resolved once from the credential
// that authenticated it. This used to read the record's own org_id, which
// was mirrored from the `owner` claim — the org of the APPLICATION the token
// came through — so a record created by a member of alpha was stamped with
// whichever org owned the sign-in app.
owner := ""
if e.Auth != nil { // IAM JWT session
owner = e.Auth.Id
} else if sub, _ := e.Get("authSub").(string); sub != "" { // IAM API key (pk-/sk-)
owner = sub
}
org, _ := e.Get(apis.RequestEventKeyOrg).(string)
if hasOrg {
if org == "" {
return e.ForbiddenError("Organization context required to create this record.", nil)
}
e.Record.Set("org", org)
}
if hasOwner {
e.Record.Set("owner", owner) // may be "" for a pure-anon public submit
}
return e.Next()
}