486 lines
16 KiB
Go
486 lines
16 KiB
Go
/*
|
|
* Copyright 2018-2024 The NATS Authors
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
package jwt
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/nats-io/nkeys"
|
|
)
|
|
|
|
// NoLimit is used to indicate a limit field is unlimited in value.
|
|
const (
|
|
NoLimit = -1
|
|
AnyAccount = "*"
|
|
)
|
|
|
|
type AccountLimits struct {
|
|
Imports int64 `json:"imports,omitempty"` // Max number of imports
|
|
Exports int64 `json:"exports,omitempty"` // Max number of exports
|
|
WildcardExports bool `json:"wildcards,omitempty"` // Are wildcards allowed in exports
|
|
DisallowBearer bool `json:"disallow_bearer,omitempty"` // User JWT can't be bearer token
|
|
Conn int64 `json:"conn,omitempty"` // Max number of active connections
|
|
LeafNodeConn int64 `json:"leaf,omitempty"` // Max number of active leaf node connections
|
|
}
|
|
|
|
// IsUnlimited returns true if all limits are unlimited
|
|
func (a *AccountLimits) IsUnlimited() bool {
|
|
return *a == AccountLimits{NoLimit, NoLimit, true, false, NoLimit, NoLimit}
|
|
}
|
|
|
|
type NatsLimits struct {
|
|
Subs int64 `json:"subs,omitempty"` // Max number of subscriptions
|
|
Data int64 `json:"data,omitempty"` // Max number of bytes
|
|
Payload int64 `json:"payload,omitempty"` // Max message payload
|
|
}
|
|
|
|
// IsUnlimited returns true if all limits are unlimited
|
|
func (n *NatsLimits) IsUnlimited() bool {
|
|
return *n == NatsLimits{NoLimit, NoLimit, NoLimit}
|
|
}
|
|
|
|
type JetStreamLimits struct {
|
|
MemoryStorage int64 `json:"mem_storage,omitempty"` // Max number of bytes stored in memory across all streams. (0 means disabled)
|
|
DiskStorage int64 `json:"disk_storage,omitempty"` // Max number of bytes stored on disk across all streams. (0 means disabled)
|
|
Streams int64 `json:"streams,omitempty"` // Max number of streams
|
|
Consumer int64 `json:"consumer,omitempty"` // Max number of consumers
|
|
MaxAckPending int64 `json:"max_ack_pending,omitempty"` // Max ack pending of a Stream
|
|
MemoryMaxStreamBytes int64 `json:"mem_max_stream_bytes,omitempty"` // Max bytes a memory backed stream can have. (0 means disabled/unlimited)
|
|
DiskMaxStreamBytes int64 `json:"disk_max_stream_bytes,omitempty"` // Max bytes a disk backed stream can have. (0 means disabled/unlimited)
|
|
MaxBytesRequired bool `json:"max_bytes_required,omitempty"` // Max bytes required by all Streams
|
|
}
|
|
|
|
// IsUnlimited returns true if all limits are unlimited
|
|
func (j *JetStreamLimits) IsUnlimited() bool {
|
|
lim := *j
|
|
// workaround in case NoLimit was used instead of 0
|
|
if lim.MemoryMaxStreamBytes < 0 {
|
|
lim.MemoryMaxStreamBytes = 0
|
|
}
|
|
if lim.DiskMaxStreamBytes < 0 {
|
|
lim.DiskMaxStreamBytes = 0
|
|
}
|
|
if lim.MaxAckPending < 0 {
|
|
lim.MaxAckPending = 0
|
|
}
|
|
return lim == JetStreamLimits{NoLimit, NoLimit, NoLimit, NoLimit, 0, 0, 0, false}
|
|
}
|
|
|
|
type JetStreamTieredLimits map[string]JetStreamLimits
|
|
|
|
// OperatorLimits are used to limit access by an account
|
|
type OperatorLimits struct {
|
|
NatsLimits
|
|
AccountLimits
|
|
JetStreamLimits
|
|
JetStreamTieredLimits `json:"tiered_limits,omitempty"`
|
|
}
|
|
|
|
// IsJSEnabled returns if this account claim has JS enabled either through a tier or the non tiered limits.
|
|
func (o *OperatorLimits) IsJSEnabled() bool {
|
|
if len(o.JetStreamTieredLimits) > 0 {
|
|
for _, l := range o.JetStreamTieredLimits {
|
|
if l.MemoryStorage != 0 || l.DiskStorage != 0 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
l := o.JetStreamLimits
|
|
return l.MemoryStorage != 0 || l.DiskStorage != 0
|
|
}
|
|
|
|
// IsEmpty returns true if all limits are 0/false/empty.
|
|
func (o *OperatorLimits) IsEmpty() bool {
|
|
return o.NatsLimits == NatsLimits{} &&
|
|
o.AccountLimits == AccountLimits{} &&
|
|
o.JetStreamLimits == JetStreamLimits{} &&
|
|
len(o.JetStreamTieredLimits) == 0
|
|
}
|
|
|
|
// IsUnlimited returns true if all limits are unlimited
|
|
func (o *OperatorLimits) IsUnlimited() bool {
|
|
return o.AccountLimits.IsUnlimited() && o.NatsLimits.IsUnlimited() &&
|
|
o.JetStreamLimits.IsUnlimited() && len(o.JetStreamTieredLimits) == 0
|
|
}
|
|
|
|
// Validate checks that the operator limits contain valid values
|
|
func (o *OperatorLimits) Validate(vr *ValidationResults) {
|
|
// negative values mean unlimited, so all numbers are valid
|
|
if len(o.JetStreamTieredLimits) > 0 {
|
|
if (o.JetStreamLimits != JetStreamLimits{}) {
|
|
vr.AddError("JetStream Limits and tiered JetStream Limits are mutually exclusive")
|
|
}
|
|
if _, ok := o.JetStreamTieredLimits[""]; ok {
|
|
vr.AddError(`Tiered JetStream Limits can not contain a blank "" tier name`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// WeightedMapping for publishes
|
|
type WeightedMapping struct {
|
|
Subject Subject `json:"subject"`
|
|
Weight uint8 `json:"weight,omitempty"`
|
|
Cluster string `json:"cluster,omitempty"`
|
|
}
|
|
|
|
func (m *WeightedMapping) GetWeight() uint8 {
|
|
if m.Weight == 0 {
|
|
return 100
|
|
}
|
|
return m.Weight
|
|
}
|
|
|
|
type Mapping map[Subject][]WeightedMapping
|
|
|
|
func (m *Mapping) Validate(vr *ValidationResults) {
|
|
for ubFrom, wm := range (map[Subject][]WeightedMapping)(*m) {
|
|
ubFrom.Validate(vr)
|
|
perCluster := make(map[string]uint32)
|
|
total := uint32(0)
|
|
for _, e := range wm {
|
|
e.Subject.Validate(vr)
|
|
if e.GetWeight() > 100 {
|
|
vr.AddError("Mapping %q has a weight %d that exceeds 100", ubFrom, e.GetWeight())
|
|
}
|
|
if e.Cluster != "" {
|
|
t := perCluster[e.Cluster]
|
|
t += uint32(e.GetWeight())
|
|
perCluster[e.Cluster] = t
|
|
if t > 100 {
|
|
vr.AddError("Mapping %q in cluster %q exceeds 100%% among all of it's weighted to mappings", ubFrom, e.Cluster)
|
|
}
|
|
} else {
|
|
total += uint32(e.GetWeight())
|
|
}
|
|
}
|
|
if total > 100 {
|
|
vr.AddError("Mapping %q exceeds 100%% among all of it's weighted to mappings", ubFrom)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *Account) AddMapping(sub Subject, to ...WeightedMapping) {
|
|
a.Mappings[sub] = to
|
|
}
|
|
|
|
// ExternalAuthorization enables external authorization for account users.
|
|
// AuthUsers are those users specified to bypass the authorization callout and should be used for the authorization service itself.
|
|
// AllowedAccounts specifies which accounts, if any, that the authorization service can bind an authorized user to.
|
|
// The authorization response, a user JWT, will still need to be signed by the correct account.
|
|
// If optional XKey is specified, that is the public xkey (x25519) and the server will encrypt the request such that only the
|
|
// holder of the private key can decrypt. The auth service can also optionally encrypt the response back to the server using it's
|
|
// public xkey which will be in the authorization request.
|
|
type ExternalAuthorization struct {
|
|
AuthUsers StringList `json:"auth_users,omitempty"`
|
|
AllowedAccounts StringList `json:"allowed_accounts,omitempty"`
|
|
XKey string `json:"xkey,omitempty"`
|
|
}
|
|
|
|
func (ac *ExternalAuthorization) IsEnabled() bool {
|
|
return len(ac.AuthUsers) > 0
|
|
}
|
|
|
|
// HasExternalAuthorization helper function to determine if external authorization is enabled.
|
|
func (a *Account) HasExternalAuthorization() bool {
|
|
return a.Authorization.IsEnabled()
|
|
}
|
|
|
|
// EnableExternalAuthorization helper function to setup external authorization.
|
|
func (a *Account) EnableExternalAuthorization(users ...string) {
|
|
a.Authorization.AuthUsers.Add(users...)
|
|
}
|
|
|
|
func (ac *ExternalAuthorization) Validate(vr *ValidationResults) {
|
|
if len(ac.AllowedAccounts) > 0 && len(ac.AuthUsers) == 0 {
|
|
vr.AddError("External authorization cannot have accounts without users specified")
|
|
}
|
|
// Make sure users are all valid user nkeys.
|
|
// Make sure allowed accounts are all valid account nkeys.
|
|
for _, u := range ac.AuthUsers {
|
|
if !nkeys.IsValidPublicUserKey(u) {
|
|
vr.AddError("AuthUser %q is not a valid user public key", u)
|
|
}
|
|
}
|
|
for _, a := range ac.AllowedAccounts {
|
|
if a == AnyAccount && len(ac.AllowedAccounts) > 1 {
|
|
vr.AddError("AllowedAccounts can only be a list of accounts or %q", AnyAccount)
|
|
continue
|
|
} else if a == AnyAccount {
|
|
continue
|
|
} else if !nkeys.IsValidPublicAccountKey(a) {
|
|
vr.AddError("Account %q is not a valid account public key", a)
|
|
}
|
|
}
|
|
if ac.XKey != "" && !nkeys.IsValidPublicCurveKey(ac.XKey) {
|
|
vr.AddError("XKey %q is not a valid public xkey", ac.XKey)
|
|
}
|
|
}
|
|
|
|
const (
|
|
ClusterTrafficSystem = "system"
|
|
ClusterTrafficOwner = "owner"
|
|
)
|
|
|
|
type ClusterTraffic string
|
|
|
|
func (ct ClusterTraffic) Valid() error {
|
|
if ct == "" || ct == ClusterTrafficSystem || ct == ClusterTrafficOwner {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("unknown cluster traffic option: %q", ct)
|
|
}
|
|
|
|
// Account holds account specific claims data
|
|
type Account struct {
|
|
Imports Imports `json:"imports,omitempty"`
|
|
Exports Exports `json:"exports,omitempty"`
|
|
Limits OperatorLimits `json:"limits,omitempty"`
|
|
SigningKeys SigningKeys `json:"signing_keys,omitempty"`
|
|
Revocations RevocationList `json:"revocations,omitempty"`
|
|
DefaultPermissions Permissions `json:"default_permissions,omitempty"`
|
|
Mappings Mapping `json:"mappings,omitempty"`
|
|
Authorization ExternalAuthorization `json:"authorization,omitempty"`
|
|
Trace *MsgTrace `json:"trace,omitempty"`
|
|
ClusterTraffic ClusterTraffic `json:"cluster_traffic,omitempty"`
|
|
Info
|
|
GenericFields
|
|
}
|
|
|
|
// MsgTrace holds distributed message tracing configuration
|
|
type MsgTrace struct {
|
|
// Destination is the subject the server will send message traces to
|
|
// if the inbound message contains the "traceparent" header and has
|
|
// its sampled field indicating that the trace should be triggered.
|
|
Destination Subject `json:"dest,omitempty"`
|
|
// Sampling is used to set the probability sampling, that is, the
|
|
// server will get a random number between 1 and 100 and trigger
|
|
// the trace if the number is lower than this Sampling value.
|
|
// The valid range is [1..100]. If the value is not set Validate()
|
|
// will set the value to 100.
|
|
Sampling int `json:"sampling,omitempty"`
|
|
}
|
|
|
|
// Validate checks if the account is valid, based on the wrapper
|
|
func (a *Account) Validate(acct *AccountClaims, vr *ValidationResults) {
|
|
a.Imports.Validate(acct.Subject, vr)
|
|
a.Exports.Validate(vr)
|
|
a.Limits.Validate(vr)
|
|
a.DefaultPermissions.Validate(vr)
|
|
a.Mappings.Validate(vr)
|
|
a.Authorization.Validate(vr)
|
|
if a.Trace != nil {
|
|
tvr := CreateValidationResults()
|
|
a.Trace.Destination.Validate(tvr)
|
|
if !tvr.IsEmpty() {
|
|
vr.AddError("the account Trace.Destination %s", tvr.Issues[0].Description)
|
|
}
|
|
if a.Trace.Destination.HasWildCards() {
|
|
vr.AddError("the account Trace.Destination subject %q is not a valid publish subject", a.Trace.Destination)
|
|
}
|
|
if a.Trace.Sampling < 0 || a.Trace.Sampling > 100 {
|
|
vr.AddError("the account Trace.Sampling value '%d' is not valid, should be in the range [1..100]", a.Trace.Sampling)
|
|
} else if a.Trace.Sampling == 0 {
|
|
a.Trace.Sampling = 100
|
|
}
|
|
}
|
|
|
|
if !a.Limits.IsEmpty() && a.Limits.Imports >= 0 && int64(len(a.Imports)) > a.Limits.Imports {
|
|
vr.AddError("the account contains more imports than allowed by the operator")
|
|
}
|
|
|
|
// Check Imports and Exports for limit violations.
|
|
if a.Limits.Imports != NoLimit {
|
|
if int64(len(a.Imports)) > a.Limits.Imports {
|
|
vr.AddError("the account contains more imports than allowed by the operator")
|
|
}
|
|
}
|
|
if a.Limits.Exports != NoLimit {
|
|
if int64(len(a.Exports)) > a.Limits.Exports {
|
|
vr.AddError("the account contains more exports than allowed by the operator")
|
|
}
|
|
// Check for wildcard restrictions
|
|
if !a.Limits.WildcardExports {
|
|
for _, ex := range a.Exports {
|
|
if ex.Subject.HasWildCards() {
|
|
vr.AddError("the account contains wildcard exports that are not allowed by the operator")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
a.SigningKeys.Validate(vr)
|
|
a.Info.Validate(vr)
|
|
|
|
if err := a.ClusterTraffic.Valid(); err != nil {
|
|
vr.AddError("%s", err.Error())
|
|
}
|
|
}
|
|
|
|
// AccountClaims defines the body of an account JWT
|
|
type AccountClaims struct {
|
|
ClaimsData
|
|
Account `json:"nats,omitempty"`
|
|
}
|
|
|
|
// NewAccountClaims creates a new account JWT
|
|
func NewAccountClaims(subject string) *AccountClaims {
|
|
if subject == "" {
|
|
return nil
|
|
}
|
|
c := &AccountClaims{}
|
|
c.SigningKeys = make(SigningKeys)
|
|
// Set to unlimited to start. We do it this way so we get compiler
|
|
// errors if we add to the OperatorLimits.
|
|
c.Limits = OperatorLimits{
|
|
NatsLimits{NoLimit, NoLimit, NoLimit},
|
|
AccountLimits{NoLimit, NoLimit, true, false, NoLimit, NoLimit},
|
|
JetStreamLimits{0, 0, 0, 0, 0, 0, 0, false},
|
|
JetStreamTieredLimits{},
|
|
}
|
|
c.Subject = subject
|
|
c.Mappings = Mapping{}
|
|
return c
|
|
}
|
|
|
|
// Encode converts account claims into a JWT string
|
|
func (a *AccountClaims) Encode(pair nkeys.KeyPair) (string, error) {
|
|
return a.EncodeWithSigner(pair, nil)
|
|
}
|
|
|
|
func (a *AccountClaims) EncodeWithSigner(pair nkeys.KeyPair, fn SignFn) (string, error) {
|
|
if !nkeys.IsValidPublicAccountKey(a.Subject) {
|
|
return "", errors.New("expected subject to be account public key")
|
|
}
|
|
sort.Sort(a.Exports)
|
|
sort.Sort(a.Imports)
|
|
a.Type = AccountClaim
|
|
return a.ClaimsData.encode(pair, a, fn)
|
|
}
|
|
|
|
// DecodeAccountClaims decodes account claims from a JWT string
|
|
func DecodeAccountClaims(token string) (*AccountClaims, error) {
|
|
claims, err := Decode(token)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ac, ok := claims.(*AccountClaims)
|
|
if !ok {
|
|
return nil, errors.New("not account claim")
|
|
}
|
|
return ac, nil
|
|
}
|
|
|
|
func (a *AccountClaims) String() string {
|
|
return a.ClaimsData.String(a)
|
|
}
|
|
|
|
// Payload pulls the accounts specific payload out of the claims
|
|
func (a *AccountClaims) Payload() interface{} {
|
|
return &a.Account
|
|
}
|
|
|
|
// Validate checks the accounts contents
|
|
func (a *AccountClaims) Validate(vr *ValidationResults) {
|
|
a.ClaimsData.Validate(vr)
|
|
a.Account.Validate(a, vr)
|
|
|
|
if nkeys.IsValidPublicAccountKey(a.ClaimsData.Issuer) {
|
|
if !a.Limits.IsEmpty() {
|
|
vr.AddWarning("self-signed account JWTs shouldn't contain operator limits")
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *AccountClaims) ClaimType() ClaimType {
|
|
return a.Type
|
|
}
|
|
|
|
func (a *AccountClaims) updateVersion() {
|
|
a.GenericFields.Version = libVersion
|
|
}
|
|
|
|
// ExpectedPrefixes defines the types that can encode an account jwt, account and operator
|
|
func (a *AccountClaims) ExpectedPrefixes() []nkeys.PrefixByte {
|
|
return []nkeys.PrefixByte{nkeys.PrefixByteAccount, nkeys.PrefixByteOperator}
|
|
}
|
|
|
|
// Claims returns the accounts claims data
|
|
func (a *AccountClaims) Claims() *ClaimsData {
|
|
return &a.ClaimsData
|
|
}
|
|
func (a *AccountClaims) GetTags() TagList {
|
|
return a.Account.Tags
|
|
}
|
|
|
|
// DidSign checks the claims against the account's public key and its signing keys
|
|
func (a *AccountClaims) DidSign(c Claims) bool {
|
|
if c != nil {
|
|
issuer := c.Claims().Issuer
|
|
if issuer == a.Subject {
|
|
return true
|
|
}
|
|
uc, ok := c.(*UserClaims)
|
|
if ok && uc.IssuerAccount == a.Subject {
|
|
return a.SigningKeys.Contains(issuer)
|
|
}
|
|
at, ok := c.(*ActivationClaims)
|
|
if ok && at.IssuerAccount == a.Subject {
|
|
return a.SigningKeys.Contains(issuer)
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Revoke enters a revocation by public key using time.Now().
|
|
func (a *AccountClaims) Revoke(pubKey string) {
|
|
a.RevokeAt(pubKey, time.Now())
|
|
}
|
|
|
|
// RevokeAt enters a revocation by public key and timestamp into this account
|
|
// This will revoke all jwt issued for pubKey, prior to timestamp
|
|
// If there is already a revocation for this public key that is newer, it is kept.
|
|
// The value is expected to be a public key or "*" (means all public keys)
|
|
func (a *AccountClaims) RevokeAt(pubKey string, timestamp time.Time) {
|
|
if a.Revocations == nil {
|
|
a.Revocations = RevocationList{}
|
|
}
|
|
a.Revocations.Revoke(pubKey, timestamp)
|
|
}
|
|
|
|
// ClearRevocation removes any revocation for the public key
|
|
func (a *AccountClaims) ClearRevocation(pubKey string) {
|
|
a.Revocations.ClearRevocation(pubKey)
|
|
}
|
|
|
|
// isRevoked checks if the public key is in the revoked list with a timestamp later than the one passed in.
|
|
// Generally this method is called with the subject and issue time of the jwt to be tested.
|
|
// DO NOT pass time.Now(), it will not produce a stable/expected response.
|
|
func (a *AccountClaims) isRevoked(pubKey string, claimIssuedAt time.Time) bool {
|
|
return a.Revocations.IsRevoked(pubKey, claimIssuedAt)
|
|
}
|
|
|
|
// IsClaimRevoked checks if the account revoked the claim passed in.
|
|
// Invalid claims (nil, no Subject or IssuedAt) will return true.
|
|
func (a *AccountClaims) IsClaimRevoked(claim *UserClaims) bool {
|
|
if claim == nil || claim.IssuedAt == 0 || claim.Subject == "" {
|
|
return true
|
|
}
|
|
return a.isRevoked(claim.Subject, time.Unix(claim.IssuedAt, 0))
|
|
}
|