{"record":{"id":"c41d22b07042c9b9","repo":"JuliusBrussee/caveman","slug":"envelope-tenant-scope-mismatch","errorCode":null,"errorMessage":"envelope: tenant scope mismatch","messagePattern":"envelope: tenant scope mismatch","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"shared/platform/envelope/envelope.go","lineNumber":134,"sourceCode":"// OpenForScope opens v2 ciphertext only for its authenticated scope. It also\n// reads v1 ciphertext during migration; all new tenant-object writes use v2.\nfunc OpenForScope(ciphertext []byte, metaJSON []byte, scope Scope) ([]byte, error) {\n\tvar meta Metadata\n\tif err := json.Unmarshal(metaJSON, &meta); err != nil {\n\t\treturn nil, fmt.Errorf(\"envelope: parse metadata: %w\", err)\n\t}\n\tif meta.Scheme == schemeV1 {\n\t\treturn open(ciphertext, meta, nil)\n\t}\n\tif meta.Scheme != schemeV2 {\n\t\treturn nil, fmt.Errorf(\"envelope: unknown scheme %q\", meta.Scheme)\n\t}\n\taad, scopeHash, err := scopeAAD(scope)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif meta.ScopeHash != scopeHash {\n\t\treturn nil, fmt.Errorf(\"envelope: tenant scope mismatch\")\n\t}\n\treturn open(ciphertext, meta, aad)\n}\n\nfunc open(ciphertext []byte, meta Metadata, aad []byte) ([]byte, error) {\n\twrapped, err := base64.StdEncoding.DecodeString(meta.WrappedDataKey)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"envelope: decode wrapped key: %w\", err)\n\t}\n\tdataKey, err := secretbox.DecryptPayloadKey(wrapped)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"envelope: unwrap data key: %w\", err)\n\t}\n\tblock, err := aes.NewCipher(dataKey)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"envelope: aes: %w\", err)\n\t}\n\tgcm, err := cipher.NewGCM(block)","sourceCodeStart":116,"sourceCodeEnd":152,"githubUrl":"https://github.com/JuliusBrussee/caveman/blob/3ee70a102609e550bd2e68004bf5990a9341c851/shared/platform/envelope/envelope.go#L116-L152","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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"],"exampleFix":"// before\nplain, err := envelope.OpenForScope(ciphertext, metaJSON, scopeFromCache(objID))\n// after\nif obj.TenantID != ctxTenant.ID {\n\treturn ErrNotFound // don't attempt cross-tenant open\n}\nplain, err := envelope.OpenForScope(ciphertext, metaJSON, envelope.Scope{TenantID: obj.TenantID, Class: obj.Class})","handlingStrategy":"validation","validationCode":"if obj.TenantID != req.TenantID {\n\treturn ErrNotFound // never attempt cross-tenant open\n}\nscope := envelope.Scope{TenantID: obj.TenantID, Class: obj.Class}\nplain, err := envelope.OpenForScope(ciphertext, metaJSON, scope)","typeGuard":null,"tryCatchPattern":"plain, err := envelope.OpenForScope(ciphertext, metaJSON, scope)\nif err != nil && strings.Contains(err.Error(), \"tenant scope mismatch\") {\n\t// treat as not-found for the caller; do not leak existence\n\treturn ErrNotFound\n}","preventionTips":["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"],"tags":["go","envelope-encryption","tenant-isolation","security"],"backgroundTag":"permission-denied","analyzedSha":"3ee70a102609e550bd2e68004bf5990a9341c851","analyzedAt":"2026-09-20T15:53:39.229Z","contentChangedAt":"2026-09-20T15:53:39.229Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}