zester

JWT Hierarchy

Zester uses NATS JWTs to encode authorization claims in a three-level trust chain: operator signs account, account signs user. Each level delegates a bounded set of permissions to the level below.

Trust Chain

The chain is validated bottom-up: a user JWT is only valid if its issuer is the account (or one of the account's signing keys), and the account JWT is only valid if its issuer is the operator (or one of the operator's signing keys).

Operator JWT

The operator is the root of trust. There is typically one operator per Zester deployment.

OperatorJWTOptions

FieldTypeDefaultDescription
Namestring""Human-readable name for the operator
SigningKeys[]stringnilAdditional public keys authorized to sign account JWTs on behalf of this operator
SystemAccountstring""Public key of the system account (enables NATS system events)
StrictSignKeysboolfalseWhen true, only signing keys (not the operator key itself) can sign account JWTs

Creating an Operator JWT

operatorKP, _ := auth.GenerateKeyBundle(auth.RoleOperator)

operatorJWT, err := auth.CreateOperatorJWT(operatorKP, auth.OperatorJWTOptions{
    Name:           "prod-operator",
    StrictSignKeys: true,
    SigningKeys:    []string{signingKeyPub},
})

Signing keys

Signing keys allow you to keep the root operator key offline. Generate a separate operator key pair, add its public key to SigningKeys, and use that key pair for day-to-day account signing. If a signing key is compromised, revoke it without rotating the root operator.

Account JWT

An account defines a namespace boundary and resource limits. Zester typically uses one account for all peels in an environment.

AccountJWTOptions

FieldTypeDefaultDescription
Namestring""Human-readable name for the account
SigningKeys[]stringnilAdditional public keys authorized to sign user JWTs under this account
MaxConnsint640 (unlimited)Maximum concurrent connections allowed
MaxDataint640 (unlimited)Maximum data in bytes across all connections
MaxPayloadint640 (unlimited)Maximum message payload size in bytes
AllowPub[]stringnilDefault publish permission subjects for users in this account
AllowSub[]stringnilDefault subscribe permission subjects for users in this account
IssuerAccountstring""Set automatically when signed by an operator signing key (rather than the operator key itself)
JetStreamboolfalseWhen true, enables JetStream for this account with unlimited storage and streams

Creating an Account JWT

accountKP, _ := auth.GenerateKeyBundle(auth.RoleAccount)

accountJWT, err := auth.CreateAccountJWT(accountKP, operatorKP, auth.AccountJWTOptions{
    Name:       "production",
    MaxConns:   1000,
    MaxPayload: 1024 * 1024, // 1MB
    AllowPub:   []string{"zester.>"},
    AllowSub:   []string{"zester.>"},
})

The second argument (operatorKP) is the signing key -- either the operator's own key bundle or a designated signing key.

User JWT

A user JWT represents a single authenticated identity. In Zester, each peel gets its own user JWT with permissions scoped to that peel's ID.

UserJWTOptions

FieldTypeDefaultDescription
Namestring""Human-readable name (typically the peel ID)
AllowPub[]stringnilSubjects the user may publish to
AllowSub[]stringnilSubjects the user may subscribe to
DenyPub[]stringnilSubjects explicitly denied for publish
DenySub[]stringnilSubjects explicitly denied for subscribe
MaxPayloadint640 (unlimited)Maximum message payload size in bytes
BearerTokenboolfalseWhen true, the JWT can be used without the nkey seed (less secure)
Expirytime.Duration0 (no expiry)How long until the JWT expires
IssuerAccountstring""The account public key. Required when signed by an account signing key.

Creating a User JWT

userKP, _ := auth.GenerateKeyBundle(auth.RoleUser)

userJWT, err := auth.CreateUserJWT(userKP, accountKP, auth.UserJWTOptions{
    Name:          "web-server-01",
    IssuerAccount: accountKP.PublicKey,
    AllowPub:      []string{"zester.event.web-server-01.>"},
    AllowSub:      []string{"zester.cmd.web-server-01.>"},
    Expiry:        24 * time.Hour,
})

Peel User JWT Defaults

The PeelUserJWTOptions helper generates a UserJWTOptions pre-configured with the exact subjects a peel needs:

opts := auth.PeelUserJWTOptions("web-server-01", accountKP.PublicKey)

This sets the following permissions:

Publish (peel sends to):

Subject PatternPurpose
zester.event.<peelID>.>Emit events (event.send module and beacons; captured by the events stream that feeds the reactor — the subject token is the trust anchor for event origin)
zester.fact.<peelID>Report facts
zester.job.*.ack.<peelID>Acknowledge job receipt
zester.job.*.return.<peelID>Return job results
$JS.API.STREAM.INFO.KV_factsJetStream: facts bucket info
$JS.API.DIRECT.GET.KV_facts.>JetStream: direct get from facts
$JS.API.STREAM.MSG.GET.KV_factsJetStream: message get from facts
$JS.API.CONSUMER.CREATE.KV_factsJetStream: create consumer on facts
$JS.API.CONSUMER.CREATE.KV_facts.>JetStream: create named consumer on facts
$JS.API.CONSUMER.DELETE.KV_facts.>JetStream: delete consumer on facts
$JS.API.STREAM.INFO.KV_settings-filesJetStream: settings-files bucket info
$JS.API.DIRECT.GET.KV_settings-files.>JetStream: direct get from settings-files
$JS.API.STREAM.MSG.GET.KV_settings-filesJetStream: message get from settings-files
$JS.API.CONSUMER.CREATE.KV_settings-filesJetStream: create consumer on settings-files
$JS.API.CONSUMER.CREATE.KV_settings-files.>JetStream: create named consumer on settings-files
$JS.API.CONSUMER.DELETE.KV_settings-files.>JetStream: delete consumer on settings-files
$JS.API.STREAM.INFO.KV_secretsJetStream: secrets bucket info
$JS.API.DIRECT.GET.KV_secrets.>JetStream: direct get from secrets
$JS.API.STREAM.MSG.GET.KV_secretsJetStream: message get from secrets
$JS.API.CONSUMER.CREATE.KV_secretsJetStream: create consumer on secrets
$JS.API.CONSUMER.CREATE.KV_secrets.>JetStream: create named consumer on secrets
$JS.API.CONSUMER.DELETE.KV_secrets.>JetStream: delete consumer on secrets
$JS.API.STREAM.INFO.KV_basketJetStream: basket bucket info
$JS.API.DIRECT.GET.KV_basket.>JetStream: direct get from basket
$JS.API.STREAM.MSG.GET.KV_basketJetStream: message get from basket
$JS.API.CONSUMER.CREATE.KV_basketJetStream: create consumer on basket
$JS.API.CONSUMER.CREATE.KV_basket.>JetStream: create named consumer on basket
$JS.API.CONSUMER.DELETE.KV_basket.>JetStream: delete consumer on basket
$JS.API.STREAM.INFO.KV_state-filesJetStream: state-files bucket info
$JS.API.DIRECT.GET.KV_state-files.>JetStream: direct get from state-files
$JS.API.STREAM.MSG.GET.KV_state-filesJetStream: message get from state-files
$JS.API.CONSUMER.CREATE.KV_state-filesJetStream: create consumer on state-files
$JS.API.CONSUMER.CREATE.KV_state-files.>JetStream: create named consumer on state-files
$JS.API.CONSUMER.DELETE.KV_state-files.>JetStream: delete consumer on state-files
$KV.basket.<peelID>.>Write own basket data to KV
$KV.facts.<peelID>Write own facts to KV
_INBOX.>NATS request-reply inbox

Subscribe (peel listens to):

Subject PatternPurpose
zester.cmd.<peelID>Receive commands
zester.cmd.<peelID>.>Receive sub-commands
zester.job.*.cancelReceive job cancellation signals
$KV.settings-files.>Read settings template files from KV
$KV.secrets._master_curve_pubRead master's curve public key
$KV.secrets.<peelID>Read own encrypted secrets from KV
$KV.basket.>Read all basket data from KV
$KV.state-files.>Read state files from KV
_INBOX.>NATS request-reply inbox

Least privilege

Each peel can only publish to its own subjects and subscribe to commands targeted at it. A compromised peel cannot impersonate other peels or intercept their traffic.

Full Hierarchy Generation

For bootstrapping or testing, generate a complete operator-account-user chain in one call:

operatorKP, accountKP, userKP,
operatorJWT, accountJWT, userJWT,
err := auth.GenerateFullHierarchy("prod-ops", "production", "master-user")

This creates three key bundles and three signed JWTs with default options.

Chain Validation

Validate that a set of JWTs form a valid trust chain:

err := auth.ValidateJWTChain(operatorJWT, accountJWT, userJWT)

This checks:

  1. The operator JWT is well-formed and self-signed.
  2. The account JWT's issuer matches the operator's public key or one of its signing keys.
  3. The user JWT's issuer matches the account's public key or one of its signing keys.

Verifying a Single User JWT

For a single token, decode it and then validate issuer/expiry in your caller:

claims, err := auth.DecodeUserJWT(userJWT)
if err != nil {
    return err
}
if claims.Issuer != accountKP.PublicKey && claims.IssuerAccount != accountKP.PublicKey {
    return fmt.Errorf("user JWT not issued by expected account")
}
if claims.Expires > 0 && time.Now().Unix() > claims.Expires {
    return fmt.Errorf("user JWT expired")
}

For full trust validation, prefer ValidateJWTChain(operatorJWT, accountJWT, userJWT).

Decoding JWTs

Decode individual JWTs to inspect their claims:

operatorClaims, err := auth.DecodeOperatorJWT(operatorJWT)
accountClaims, err := auth.DecodeAccountJWT(accountJWT)
userClaims, err := auth.DecodeUserJWT(userJWT)

Signing Keys

Signing keys provide operational flexibility. Instead of using the root operator or account key for every signing operation, generate dedicated signing keys and reference them:

// Generate a signing key (same type as the parent)
signingKP, _ := auth.GenerateKeyBundle(auth.RoleOperator)

// Include it in the operator JWT
operatorJWT, _ := auth.CreateOperatorJWT(operatorKP, auth.OperatorJWTOptions{
    Name:       "prod-ops",
    SigningKeys: []string{signingKP.PublicKey},
})

// Now sign accounts with the signing key instead of the root key
accountJWT, _ := auth.CreateAccountJWT(accountKP, signingKP, auth.AccountJWTOptions{
    Name: "production",
})

Benefits of signing keys:

  • The root key can stay offline (cold storage, HSM).
  • Signing keys can be rotated without re-issuing the operator JWT (just add the new key and remove the old one).
  • If a signing key is compromised, only JWTs signed by that key need re-issuing.

On this page