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

  1. Verify the Scope passed in matches the owning tenant of the object (check the tenant ID on the request context)
  2. Confirm the row/object actually belongs to the caller's tenant before attempting open
  3. Check any caching or job code that fetches objects by ID without filtering by tenant
  4. 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

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


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)