JuliusBrussee/caveman · error
envelope: tenant scope mismatch
Error message
envelope: tenant scope mismatch
What it means
OpenForScope derives the scope's AAD and hash via scopeAAD and compares the hash against the one authenticated in the metadata. A mismatch means the ciphertext was sealed for a different tenant/scope, so decryption is refused outright — this is the library's tenant-isolation guarantee, enforced by AES-GCM associated data as well as this pre-check.
Solutions
- Verify the Scope passed in matches the owning tenant of the object (check the tenant ID on the request context)
- Confirm the row/object actually belongs to the caller's tenant before attempting open
- Check any caching or job code that fetches objects by ID without filtering by tenant
- If the object legitimately moved tenants, re-seal it under the new scope with SealForScope rather than forcing an open
Example fix
// before
plain, err := envelope.OpenForScope(ciphertext, metaJSON, scopeFromCache(objID))
// after
if obj.TenantID != ctxTenant.ID {
return ErrNotFound // don't attempt cross-tenant open
}
plain, err := envelope.OpenForScope(ciphertext, metaJSON, envelope.Scope{TenantID: obj.TenantID, Class: obj.Class}) Defensive patterns
Strategy: validation
Validate before calling
if obj.TenantID != req.TenantID {
return ErrNotFound // never attempt cross-tenant open
}
scope := envelope.Scope{TenantID: obj.TenantID, Class: obj.Class}
plain, err := envelope.OpenForScope(ciphertext, metaJSON, scope) Try / catch
plain, err := envelope.OpenForScope(ciphertext, metaJSON, scope)
if err != nil && strings.Contains(err.Error(), "tenant scope mismatch") {
// treat as not-found for the caller; do not leak existence
return ErrNotFound
} Prevention
- Always derive Scope from the authenticated request context, not from user input or cache keys
- Filter every object lookup by tenant ID before decryption
- Return not-found (not the raw error) on scope mismatch to avoid tenant enumeration
- Test cross-tenant access in CI with two-tenant fixtures
When it happens
Trigger: Calling OpenForScope with a Scope whose tenant ID/object class differs from the one used at SealForScope time — e.g. cross-tenant read, wrong tenant ID loaded from the request context, or reading another tenant's row by mistake.
Common situations: Multi-tenant apps where the request's tenant context is missing or defaulted, keys/IDs swapped in a cache, or a migration copying objects without preserving their owning scope.
Understand the failure class
Background: Permission denied / not authorized / 403 Forbidden: access-control rejections when the caller lacks the required role, grant, or ownership — this error's family across 18 libraries.
Related errors
- ambiguous escaped path sequence
- backslash is not allowed in path
- compat route path rejected
- compat upstream forward_headers
- dot segments are not allowed in path
AI-assisted analysis of JuliusBrussee/caveman@3ee70a1026 (2026-09-20).
Data as JSON: /api/errors/c41d22b07042c9b9.
Report an issue: GitHub.
Appendix: source
Thrown at shared/platform/envelope/envelope.go:134
// OpenForScope opens v2 ciphertext only for its authenticated scope. It also
// reads v1 ciphertext during migration; all new tenant-object writes use v2.
func OpenForScope(ciphertext []byte, metaJSON []byte, scope Scope) ([]byte, error) {
var meta Metadata
if err := json.Unmarshal(metaJSON, &meta); err != nil {
return nil, fmt.Errorf("envelope: parse metadata: %w", err)
}
if meta.Scheme == schemeV1 {
return open(ciphertext, meta, nil)
}
if meta.Scheme != schemeV2 {
return nil, fmt.Errorf("envelope: unknown scheme %q", meta.Scheme)
}
aad, scopeHash, err := scopeAAD(scope)
if err != nil {
return nil, err
}
if meta.ScopeHash != scopeHash {
return nil, fmt.Errorf("envelope: tenant scope mismatch")
}
return open(ciphertext, meta, aad)
}
func open(ciphertext []byte, meta Metadata, aad []byte) ([]byte, error) {
wrapped, err := base64.StdEncoding.DecodeString(meta.WrappedDataKey)
if err != nil {
return nil, fmt.Errorf("envelope: decode wrapped key: %w", err)
}
dataKey, err := secretbox.DecryptPayloadKey(wrapped)
if err != nil {
return nil, fmt.Errorf("envelope: unwrap data key: %w", err)
}
block, err := aes.NewCipher(dataKey)
if err != nil {
return nil, fmt.Errorf("envelope: aes: %w", err)
}
gcm, err := cipher.NewGCM(block)View on GitHub (pinned to 3ee70a1026)