feat: consume shared predastore crypto + iampolicy (#461)

This commit is contained in:
Julian Sommer
2026-07-01 12:45:47 +10:00
parent 7371358537
commit 12c8f65855
9 changed files with 83 additions and 581 deletions
+2 -1
View File
@@ -16,6 +16,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/mulgadc/predastore/auth"
"github.com/mulgadc/predastore/pkg/iampolicy"
"github.com/mulgadc/predastore/ratelimit"
"github.com/mulgadc/spinifex/spinifex/awserrors"
gateway_ecr "github.com/mulgadc/spinifex/spinifex/gateway/ecr"
@@ -475,7 +476,7 @@ func (gw *GatewayConfig) checkPolicyResource(r *http.Request, service, action, r
return errors.New(awserrors.ErrorInternalError)
}
if policy.EvaluateAccess(logIdentity, iamAction, resource, policies) == policy.Deny {
if iampolicy.Evaluate(iamAction, resource, policies) == iampolicy.Deny {
slog.Info("checkPolicy: access denied", "identity", logIdentity, "action", iamAction, "resource", resource)
return errors.New(awserrors.ErrorAccessDenied)
}
-72
View File
@@ -1,72 +0,0 @@
// Package policy implements IAM policy evaluation for access control decisions.
package policy
import (
"log/slog"
"strings"
"github.com/mulgadc/spinifex/spinifex/filterutil"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
)
// Decision represents the outcome of a policy evaluation.
type Decision int
const (
// Deny is the default — no matching Allow, or an explicit Deny.
Deny Decision = iota
// Allow means an explicit Allow was found with no overriding Deny.
Allow
)
// EvaluateAccess checks whether the given identity is permitted to perform
// the specified action on the specified resource, based on the supplied
// policy documents. It follows AWS's evaluation order:
//
// 1. Explicit Deny in any statement → Deny (wins immediately).
// 2. Explicit Allow in any statement → Allow.
// 3. No matching statement → Deny (implicit default).
//
// Root bypass is handled by the gateway before this function is called.
func EvaluateAccess(identity, action, resource string, policies []handlers_iam.PolicyDocument) Decision {
hasAllow := false
for i := range policies {
for j := range policies[i].Statement {
stmt := &policies[i].Statement[j] //nolint:gosec // G602 false positive: j is bounded by range
if !matchesAny(stmt.Action, action) {
continue
}
if !matchesAny(stmt.Resource, resource) {
continue
}
switch stmt.Effect {
case handlers_iam.PolicyEffectDeny:
return Deny
case handlers_iam.PolicyEffectAllow:
hasAllow = true
default:
slog.Warn("EvaluateAccess: unrecognized Effect, treating as Deny",
"effect", stmt.Effect, "action", action, "identity", identity)
return Deny
}
}
}
if hasAllow {
return Allow
}
return Deny
}
// matchesAny reports whether any pattern matches value using AWS IAM wildcard
// semantics. Comparison is case-insensitive.
func matchesAny(patterns []string, value string) bool {
lv := strings.ToLower(value)
for _, p := range patterns {
if filterutil.MatchWildcard(strings.ToLower(p), lv) {
return true
}
}
return false
}
+29 -379
View File
@@ -3,10 +3,11 @@ package policy
import (
"testing"
"github.com/mulgadc/predastore/pkg/iampolicy"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
)
// helper to build a single-statement policy document.
// doc builds a single-statement policy document.
func doc(effect, action, resource string) handlers_iam.PolicyDocument {
return handlers_iam.PolicyDocument{
Version: "2012-10-17",
@@ -16,303 +17,15 @@ func doc(effect, action, resource string) handlers_iam.PolicyDocument {
}
}
// Root bypass is tested at the gateway layer (checkPolicy), not in the evaluator.
// The deny-wins algorithm and wildcard matcher are unit-tested in predastore's
// pkg/iampolicy. These tests pin spinifex's own action-string conventions end to
// end through iampolicy.Evaluate, exercising the aliased handlers_iam DTOs.
func TestEvaluateAccess_DefaultDeny(t *testing.T) {
// Non-root with no policies → default deny.
got := EvaluateAccess("alice", "ec2:RunInstances", "*", nil)
if got != Deny {
t.Fatalf("expected default Deny with no policies, got %v", got)
}
}
func TestEvaluateAccess_DefaultDenyEmptyPolicies(t *testing.T) {
// Non-root with empty policy list → default deny.
got := EvaluateAccess("alice", "ec2:RunInstances", "*", []handlers_iam.PolicyDocument{})
if got != Deny {
t.Fatalf("expected default Deny with empty policies, got %v", got)
}
}
func TestEvaluateAccess_ExplicitAllow(t *testing.T) {
policies := []handlers_iam.PolicyDocument{
doc("Allow", "ec2:RunInstances", "*"),
}
got := EvaluateAccess("alice", "ec2:RunInstances", "*", policies)
if got != Allow {
t.Fatalf("expected Allow, got %v", got)
}
}
func TestEvaluateAccess_ExplicitDenyWins(t *testing.T) {
// Explicit deny overrides an explicit allow.
policies := []handlers_iam.PolicyDocument{
doc("Allow", "ec2:*", "*"),
doc("Deny", "ec2:TerminateInstances", "*"),
}
got := EvaluateAccess("alice", "ec2:TerminateInstances", "*", policies)
if got != Deny {
t.Fatalf("expected Deny (explicit deny wins), got %v", got)
}
}
func TestEvaluateAccess_ExplicitDenyWinsSamePolicy(t *testing.T) {
// Deny and Allow in the same policy document — Deny wins.
policies := []handlers_iam.PolicyDocument{
{
Version: "2012-10-17",
Statement: []handlers_iam.Statement{
{Effect: "Allow", Action: handlers_iam.StringOrArr{"ec2:*"}, Resource: handlers_iam.StringOrArr{"*"}},
{Effect: "Deny", Action: handlers_iam.StringOrArr{"ec2:TerminateInstances"}, Resource: handlers_iam.StringOrArr{"*"}},
},
},
}
got := EvaluateAccess("alice", "ec2:TerminateInstances", "*", policies)
if got != Deny {
t.Fatalf("expected Deny (same-policy explicit deny), got %v", got)
}
}
func TestEvaluateAccess_NoMatchingAction(t *testing.T) {
policies := []handlers_iam.PolicyDocument{
doc("Allow", "s3:GetObject", "*"),
}
got := EvaluateAccess("alice", "ec2:RunInstances", "*", policies)
if got != Deny {
t.Fatalf("expected Deny (no matching action), got %v", got)
}
}
func TestEvaluateAccess_WildcardAllActions(t *testing.T) {
policies := []handlers_iam.PolicyDocument{
doc("Allow", "*", "*"),
}
got := EvaluateAccess("alice", "ec2:RunInstances", "*", policies)
if got != Allow {
t.Fatalf("expected Allow with wildcard *, got %v", got)
}
}
func TestEvaluateAccess_ServiceWildcard(t *testing.T) {
policies := []handlers_iam.PolicyDocument{
doc("Allow", "ec2:*", "*"),
}
tests := []struct {
action string
want Decision
}{
{"ec2:RunInstances", Allow},
{"ec2:DescribeInstances", Allow},
{"s3:GetObject", Deny},
{"iam:CreateUser", Deny},
}
for _, tt := range tests {
got := EvaluateAccess("alice", tt.action, "*", policies)
if got != tt.want {
t.Errorf("ec2:* policy, action=%s: expected %v, got %v", tt.action, tt.want, got)
}
}
}
func TestEvaluateAccess_PrefixWildcard(t *testing.T) {
policies := []handlers_iam.PolicyDocument{
doc("Allow", "s3:Get*", "*"),
}
tests := []struct {
action string
want Decision
}{
{"s3:GetObject", Allow},
{"s3:GetBucketPolicy", Allow},
{"s3:PutObject", Deny},
{"s3:DeleteObject", Deny},
}
for _, tt := range tests {
got := EvaluateAccess("alice", tt.action, "*", policies)
if got != tt.want {
t.Errorf("s3:Get* policy, action=%s: expected %v, got %v", tt.action, tt.want, got)
}
}
}
func TestEvaluateAccess_MultipleActions(t *testing.T) {
// A statement with multiple actions.
policies := []handlers_iam.PolicyDocument{
{
Version: "2012-10-17",
Statement: []handlers_iam.Statement{
{
Effect: "Allow",
Action: handlers_iam.StringOrArr{"ec2:DescribeInstances", "ec2:RunInstances"},
Resource: handlers_iam.StringOrArr{"*"},
},
},
},
}
tests := []struct {
action string
want Decision
}{
{"ec2:DescribeInstances", Allow},
{"ec2:RunInstances", Allow},
{"ec2:TerminateInstances", Deny},
}
for _, tt := range tests {
got := EvaluateAccess("alice", tt.action, "*", policies)
if got != tt.want {
t.Errorf("multi-action policy, action=%s: expected %v, got %v", tt.action, tt.want, got)
}
}
}
func TestEvaluateAccess_MultiplePolicies(t *testing.T) {
// Permissions spread across multiple policy documents.
policies := []handlers_iam.PolicyDocument{
doc("Allow", "ec2:DescribeInstances", "*"),
doc("Allow", "s3:GetObject", "*"),
}
tests := []struct {
action string
want Decision
}{
{"ec2:DescribeInstances", Allow},
{"s3:GetObject", Allow},
{"iam:CreateUser", Deny},
}
for _, tt := range tests {
got := EvaluateAccess("alice", tt.action, "*", policies)
if got != tt.want {
t.Errorf("multi-policy, action=%s: expected %v, got %v", tt.action, tt.want, got)
}
}
}
func TestEvaluateAccess_ResourceMismatch(t *testing.T) {
// Allow only on a specific resource, request uses "*".
policies := []handlers_iam.PolicyDocument{
doc("Allow", "s3:GetObject", "arn:aws:s3:::my-bucket/*"),
}
got := EvaluateAccess("alice", "s3:GetObject", "*", policies)
if got != Deny {
t.Fatalf("expected Deny (resource mismatch), got %v", got)
}
}
func TestEvaluateAccess_CaseInsensitiveAction(t *testing.T) {
// Action matching should be case-insensitive for exact matches.
policies := []handlers_iam.PolicyDocument{
doc("Allow", "EC2:RunInstances", "*"),
}
got := EvaluateAccess("alice", "ec2:RunInstances", "*", policies)
if got != Allow {
t.Fatalf("expected Allow (case-insensitive match), got %v", got)
}
}
// --- wildcard matching tests (via matchesAny) ---
func TestMatchesAny_Wildcard(t *testing.T) {
tests := []struct {
pattern string
value string
want bool
}{
// Global wildcard
{"*", "anything", true},
{"*", "", true},
// Service wildcard
{"ec2:*", "ec2:RunInstances", true},
{"ec2:*", "ec2:DescribeInstances", true},
{"ec2:*", "s3:GetObject", false},
{"EC2:*", "ec2:RunInstances", true},
// Prefix wildcard
{"s3:Get*", "s3:GetObject", true},
{"s3:Get*", "s3:GetBucketPolicy", true},
{"s3:Get*", "s3:PutObject", false},
{"S3:get*", "s3:GetObject", true},
// Exact match
{"ec2:RunInstances", "ec2:RunInstances", true},
{"ec2:RunInstances", "ec2:StopInstances", false},
// Case insensitive exact match
{"EC2:RunInstances", "ec2:RunInstances", true},
{"ec2:runinstances", "ec2:RunInstances", true},
// Embedded wildcards (AWS IAM-style — required for iam:PassRole ARN matching).
{"arn:aws:iam::*:role/app-*", "arn:aws:iam::123456789012:role/app-foo", true},
{"arn:aws:iam::*:role/app-*", "arn:aws:iam::999999999999:role/app-bar", true},
{"arn:aws:iam::*:role/*", "arn:aws:iam::123456789012:role/anything", true},
{"arn:aws:iam::123456789012:role/app-*", "arn:aws:iam::123456789012:role/app-foo", true},
{"arn:aws:iam::*:role/app-*", "arn:aws:iam::123456789012:role/admin-foo", false},
{"arn:aws:iam::*:role/app-*", "arn:aws:iam::123456789012:user/app-foo", false},
{"arn:aws:iam::*:role/app-*", "arn:aws:iam::123456789012:role/app-", true},
{"a*b*c", "axxbyyc", true},
{"a*b*c", "axxbyy", false},
// Edge cases
{"", "", true},
{"", "something", false},
}
for _, tt := range tests {
got := matchesAny([]string{tt.pattern}, tt.value)
if got != tt.want {
t.Errorf("matchesAny([%q], %q) = %v, want %v", tt.pattern, tt.value, got, tt.want)
}
}
}
// TestEvaluateAccess_PassRoleResourceARN exercises the resource-ARN matching
// path used by iam:PassRole enforcement. The caller's policy will typically
// scope PassRole to a wildcard ARN; the evaluator must match a concrete role
// ARN against it.
func TestEvaluateAccess_PassRoleResourceARN(t *testing.T) {
policies := []handlers_iam.PolicyDocument{
{
Version: "2012-10-17",
Statement: []handlers_iam.Statement{
{
Effect: "Allow",
Action: handlers_iam.StringOrArr{"iam:PassRole"},
Resource: handlers_iam.StringOrArr{"arn:aws:iam::*:role/app-*"},
},
},
},
}
tests := []struct {
resource string
want Decision
}{
{"arn:aws:iam::123456789012:role/app-foo", Allow},
{"arn:aws:iam::999999999999:role/app-bar", Allow},
{"arn:aws:iam::123456789012:role/admin-foo", Deny},
{"arn:aws:iam::123456789012:user/app-foo", Deny},
}
for _, tt := range tests {
got := EvaluateAccess("alice", "iam:PassRole", tt.resource, policies)
if got != tt.want {
t.Errorf("PassRole on %s: expected %v, got %v", tt.resource, tt.want, got)
}
}
}
// TestEvaluateAccess_IAMInstanceProfileActionStrings pins the action strings
// for iam:PassRole + the four EC2 instance-profile association actions so a
// rename of any ec2Actions key or checkPolicyResource call site is caught at
// the policy layer. Strings are produced dynamically via policy.IAMAction.
func TestEvaluateAccess_IAMInstanceProfileActionStrings(t *testing.T) {
// TestEvaluate_IAMInstanceProfileActionStrings pins the action strings for
// iam:PassRole + the four EC2 instance-profile association actions so a rename of
// any ec2Actions key or checkPolicyResource call site is caught at the policy
// layer. Strings are produced dynamically via IAMAction.
func TestEvaluate_IAMInstanceProfileActionStrings(t *testing.T) {
actions := []string{
"iam:PassRole",
"ec2:AssociateIamInstanceProfile",
@@ -324,7 +37,7 @@ func TestEvaluateAccess_IAMInstanceProfileActionStrings(t *testing.T) {
doc("Allow", "*", "*"),
}
for _, a := range actions {
if got := EvaluateAccess("alice", a, "*", policies); got != Allow {
if got := iampolicy.Evaluate(a, "*", policies); got != iampolicy.Allow {
t.Errorf("expected Allow for action %q under wildcard policy, got %v", a, got)
}
}
@@ -336,18 +49,18 @@ func TestEvaluateAccess_IAMInstanceProfileActionStrings(t *testing.T) {
scopedTests := []struct {
action string
resource string
want Decision
want iampolicy.Decision
}{
{"ec2:AssociateIamInstanceProfile", "*", Allow},
{"ec2:DisassociateIamInstanceProfile", "*", Allow},
{"ec2:ReplaceIamInstanceProfileAssociation", "*", Allow},
{"ec2:DescribeIamInstanceProfileAssociations", "*", Allow},
{"iam:PassRole", "arn:aws:iam::123456789012:role/app-foo", Allow},
{"iam:PassRole", "arn:aws:iam::123456789012:user/app-foo", Deny},
{"ec2:RunInstances", "*", Deny},
{"ec2:AssociateIamInstanceProfile", "*", iampolicy.Allow},
{"ec2:DisassociateIamInstanceProfile", "*", iampolicy.Allow},
{"ec2:ReplaceIamInstanceProfileAssociation", "*", iampolicy.Allow},
{"ec2:DescribeIamInstanceProfileAssociations", "*", iampolicy.Allow},
{"iam:PassRole", "arn:aws:iam::123456789012:role/app-foo", iampolicy.Allow},
{"iam:PassRole", "arn:aws:iam::123456789012:user/app-foo", iampolicy.Deny},
{"ec2:RunInstances", "*", iampolicy.Deny},
}
for _, tt := range scopedTests {
got := EvaluateAccess("alice", tt.action, tt.resource, scoped)
got := iampolicy.Evaluate(tt.action, tt.resource, scoped)
if got != tt.want {
t.Errorf("scoped policy, action=%s resource=%s: expected %v, got %v",
tt.action, tt.resource, tt.want, got)
@@ -355,12 +68,12 @@ func TestEvaluateAccess_IAMInstanceProfileActionStrings(t *testing.T) {
}
}
// TestEvaluateAccess_STSActionStrings pins the action strings emitted by the
// STS gateway dispatcher (gateway/sts.go stsActions + checkPolicy(r, "sts",
// action) call site). Locks in that every STS verb the dispatcher accepts is
// matchable by the evaluator under both wildcard and service-scoped policies,
// so a future rename of any stsActions key surfaces here.
func TestEvaluateAccess_STSActionStrings(t *testing.T) {
// TestEvaluate_STSActionStrings pins the action strings emitted by the STS
// gateway dispatcher (gateway/sts.go stsActions + checkPolicy(r, "sts", action)
// call site). Locks in that every STS verb the dispatcher accepts is matchable
// by the evaluator under both wildcard and service-scoped policies, so a future
// rename of any stsActions key surfaces here.
func TestEvaluate_STSActionStrings(t *testing.T) {
actions := []string{
"sts:AssumeRole",
"sts:GetCallerIdentity",
@@ -370,17 +83,17 @@ func TestEvaluateAccess_STSActionStrings(t *testing.T) {
scoped := []handlers_iam.PolicyDocument{doc("Allow", "sts:*", "*")}
for _, a := range actions {
if got := EvaluateAccess("alice", a, "*", wildcard); got != Allow {
if got := iampolicy.Evaluate(a, "*", wildcard); got != iampolicy.Allow {
t.Errorf("wildcard policy: expected Allow for %q, got %v", a, got)
}
if got := EvaluateAccess("alice", a, "*", scoped); got != Allow {
if got := iampolicy.Evaluate(a, "*", scoped); got != iampolicy.Allow {
t.Errorf("sts:* policy: expected Allow for %q, got %v", a, got)
}
}
// Non-STS action must NOT match an sts:*-scoped policy — guards against a
// pattern regression that would over-allow.
if got := EvaluateAccess("alice", "ec2:RunInstances", "*", scoped); got != Deny {
if got := iampolicy.Evaluate("ec2:RunInstances", "*", scoped); got != iampolicy.Deny {
t.Errorf("sts:* policy: expected Deny for ec2:RunInstances, got %v", got)
}
}
@@ -393,66 +106,3 @@ func TestIAMAction(t *testing.T) {
t.Fatalf("IAMAction(ec2, RunInstances) = %q, want %q", got, "ec2:RunInstances")
}
}
// --- Realistic scenario tests ---
func TestEvaluateAccess_ReadOnlyUser(t *testing.T) {
// Simulate a read-only user: allow all Describe* actions, deny everything else.
policies := []handlers_iam.PolicyDocument{
{
Version: "2012-10-17",
Statement: []handlers_iam.Statement{
{
Effect: "Allow",
Action: handlers_iam.StringOrArr{"ec2:Describe*"},
Resource: handlers_iam.StringOrArr{"*"},
},
},
},
}
tests := []struct {
action string
want Decision
}{
{"ec2:DescribeInstances", Allow},
{"ec2:DescribeVolumes", Allow},
{"ec2:DescribeVpcs", Allow},
{"ec2:RunInstances", Deny},
{"ec2:TerminateInstances", Deny},
{"iam:CreateUser", Deny},
}
for _, tt := range tests {
got := EvaluateAccess("viewer", tt.action, "*", policies)
if got != tt.want {
t.Errorf("read-only user, action=%s: expected %v, got %v", tt.action, tt.want, got)
}
}
}
func TestEvaluateAccess_AdminWithDenyTerminate(t *testing.T) {
// Admin that can do everything except terminate instances.
policies := []handlers_iam.PolicyDocument{
doc("Allow", "*", "*"),
doc("Deny", "ec2:TerminateInstances", "*"),
}
tests := []struct {
action string
want Decision
}{
{"ec2:RunInstances", Allow},
{"ec2:DescribeInstances", Allow},
{"s3:GetObject", Allow},
{"iam:CreateUser", Allow},
{"ec2:TerminateInstances", Deny}, // explicit deny
}
for _, tt := range tests {
got := EvaluateAccess("admin", tt.action, "*", policies)
if got != tt.want {
t.Errorf("admin-no-terminate, action=%s: expected %v, got %v", tt.action, tt.want, got)
}
}
}
+1 -1
View File
@@ -350,7 +350,7 @@ func TestCrossAccount_LookupAccessKeyReturnsCorrectAccount(t *testing.T) {
func TestSeedBootstrap_AccountScoped(t *testing.T) {
svc := setupTestIAMService(t)
encryptedSecret, err := EncryptSecret("root-secret", svc.masterKey)
encryptedSecret, err := svc.key.EncryptBase64("root-secret")
require.NoError(t, err)
err = svc.SeedBootstrap(&BootstrapData{
+10 -62
View File
@@ -1,13 +1,12 @@
package handlers_iam
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"github.com/mulgadc/predastore/pkg/masterkey"
)
const masterKeySize = 32 // AES-256
@@ -48,75 +47,24 @@ func SaveMasterKey(path string, key []byte) error {
return nil
}
// EncryptSecret encrypts a plaintext secret with AES-256-GCM.
// Returns base64(nonce + ciphertext + tag).
// EncryptSecret encrypts a plaintext secret with AES-256-GCM, returning
// base64(nonce + ciphertext + tag). It delegates to predastore's masterkey so
// the wire format stays byte-for-byte identical across services.
func EncryptSecret(plaintext string, key []byte) (string, error) {
block, err := aes.NewCipher(key)
k, err := masterkey.New(key)
if err != nil {
return "", fmt.Errorf("create cipher: %w", err)
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("create GCM: %w", err)
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", fmt.Errorf("generate nonce: %w", err)
}
// Seal appends ciphertext+tag to nonce
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// Decrypter holds a pre-computed AES-256-GCM cipher for repeated decryption.
// Create once at startup with NewDecrypter and reuse across requests.
type Decrypter struct {
gcm cipher.AEAD
}
// NewDecrypter creates a Decrypter with a pre-computed AES-GCM cipher from the given key.
func NewDecrypter(key []byte) (*Decrypter, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("create GCM: %w", err)
}
return &Decrypter{gcm: gcm}, nil
}
// Decrypt decrypts a base64-encoded AES-256-GCM ciphertext using the pre-computed cipher.
func (d *Decrypter) Decrypt(ciphertext string) (string, error) {
data, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
return "", fmt.Errorf("base64 decode: %w", err)
}
nonceSize := d.gcm.NonceSize()
if len(data) < nonceSize {
return "", fmt.Errorf("ciphertext too short")
}
nonce, sealed := data[:nonceSize], data[nonceSize:]
plaintext, err := d.gcm.Open(nil, nonce, sealed, nil)
if err != nil {
return "", fmt.Errorf("decrypt: %w", err)
}
return string(plaintext), nil
return k.EncryptBase64(plaintext)
}
// DecryptSecret decrypts a base64-encoded AES-256-GCM ciphertext.
func DecryptSecret(ciphertext string, key []byte) (string, error) {
d, err := NewDecrypter(key)
k, err := masterkey.New(key)
if err != nil {
return "", err
}
return d.Decrypt(ciphertext)
return k.DecryptBase64(ciphertext)
}
// SaveBootstrapData writes bootstrap data as JSON to disk with 0600 permissions.
+8 -9
View File
@@ -14,6 +14,7 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/mulgadc/predastore/pkg/masterkey"
"github.com/mulgadc/spinifex/spinifex/admin"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/migrate"
@@ -85,8 +86,7 @@ type IAMServiceImpl struct {
rolesBucket nats.KeyValue
instanceProfilesBucket nats.KeyValue
groupsBucket nats.KeyValue
masterKey []byte
decrypter *Decrypter
key *masterkey.Key
// replicas is the JetStream replication factor for lazily-created per-account buckets.
replicas int
}
@@ -171,9 +171,9 @@ func NewIAMServiceImpl(natsConn *nats.Conn, masterKey []byte, clusterSize int) (
return nil, fmt.Errorf("migrate %s: %w", KVBucketGroups, err)
}
decrypter, err := NewDecrypter(masterKey)
key, err := masterkey.New(masterKey)
if err != nil {
return nil, fmt.Errorf("init decrypter: %w", err)
return nil, fmt.Errorf("init master key: %w", err)
}
slog.Info("IAM service initialized",
@@ -197,8 +197,7 @@ func NewIAMServiceImpl(natsConn *nats.Conn, masterKey []byte, clusterSize int) (
rolesBucket: rolesBucket,
instanceProfilesBucket: instanceProfilesBucket,
groupsBucket: groupsBucket,
masterKey: masterKey,
decrypter: decrypter,
key: key,
replicas: replicas,
}, nil
}
@@ -428,7 +427,7 @@ func (s *IAMServiceImpl) CreateAccessKey(accountID string, input *iam.CreateAcce
return nil, fmt.Errorf("generate secret key: %w", err)
}
encryptedSecret, err := EncryptSecret(secretAccessKey, s.masterKey)
encryptedSecret, err := s.key.EncryptBase64(secretAccessKey)
if err != nil {
return nil, fmt.Errorf("encrypt secret: %w", err)
}
@@ -625,9 +624,9 @@ func (s *IAMServiceImpl) LookupAccessKey(accessKeyID string) (*AccessKey, error)
}
// DecryptSecret decrypts a base64-encoded AES-256-GCM ciphertext using the
// pre-computed cipher initialized at startup.
// master key loaded at startup.
func (s *IAMServiceImpl) DecryptSecret(ciphertext string) (string, error) {
return s.decrypter.Decrypt(ciphertext)
return s.key.DecryptBase64(ciphertext)
}
// SeedBootstrap seeds the system root user and optional admin account into NATS KV.
+15 -11
View File
@@ -330,7 +330,7 @@ func TestCreateAccessKey_SecretIsDecryptable(t *testing.T) {
ak, err := svc.LookupAccessKey(accessKeyID)
require.NoError(t, err)
decrypted, err := DecryptSecret(ak.SecretAccessKey, svc.masterKey)
decrypted, err := svc.key.DecryptBase64(ak.SecretAccessKey)
require.NoError(t, err)
assert.Equal(t, plaintextSecret, decrypted)
}
@@ -649,7 +649,7 @@ func TestLookupAccessKey_InactiveKey(t *testing.T) {
func TestSeedBootstrap(t *testing.T) {
svc := setupTestIAMService(t)
encryptedSecret, err := EncryptSecret("test-secret-key", svc.masterKey)
encryptedSecret, err := svc.key.EncryptBase64("test-secret-key")
require.NoError(t, err)
err = svc.SeedBootstrap(&BootstrapData{
@@ -676,7 +676,7 @@ func TestSeedBootstrap(t *testing.T) {
assert.Equal(t, "Active", ak.Status)
// Verify secret is decryptable
decrypted, err := DecryptSecret(ak.SecretAccessKey, svc.masterKey)
decrypted, err := svc.key.DecryptBase64(ak.SecretAccessKey)
require.NoError(t, err)
assert.Equal(t, "test-secret-key", decrypted)
@@ -691,7 +691,7 @@ func TestSeedBootstrap(t *testing.T) {
func TestSeedBootstrap_Idempotent(t *testing.T) {
svc := setupTestIAMService(t)
encryptedSecret, _ := EncryptSecret("test-secret", svc.masterKey)
encryptedSecret, _ := svc.key.EncryptBase64("test-secret")
data := &BootstrapData{
AccessKeyID: "AKIAEXAMPLE123456789",
EncryptedSecret: encryptedSecret,
@@ -717,9 +717,9 @@ func TestSeedBootstrap_Idempotent(t *testing.T) {
func TestSeedBootstrap_WithAdmin(t *testing.T) {
svc := setupTestIAMService(t)
systemSecret, err := EncryptSecret("system-secret", svc.masterKey)
systemSecret, err := svc.key.EncryptBase64("system-secret")
require.NoError(t, err)
adminSecret, err := EncryptSecret("admin-secret", svc.masterKey)
adminSecret, err := svc.key.EncryptBase64("admin-secret")
require.NoError(t, err)
err = svc.SeedBootstrap(&BootstrapData{
@@ -764,7 +764,7 @@ func TestSeedBootstrap_WithAdmin(t *testing.T) {
assert.Equal(t, "admin", ak.UserName)
assert.Equal(t, "000000000001", ak.AccountID)
decrypted, err := DecryptSecret(ak.SecretAccessKey, svc.masterKey)
decrypted, err := svc.key.DecryptBase64(ak.SecretAccessKey)
require.NoError(t, err)
assert.Equal(t, "admin-secret", decrypted)
@@ -785,7 +785,7 @@ func TestSeedBootstrap_WithAdmin(t *testing.T) {
func TestSeedBootstrap_AdminNil_BackwardCompat(t *testing.T) {
svc := setupTestIAMService(t)
encryptedSecret, err := EncryptSecret("test-secret", svc.masterKey)
encryptedSecret, err := svc.key.EncryptBase64("test-secret")
require.NoError(t, err)
// No Admin field — backward compatibility with old bootstrap.json
@@ -1595,14 +1595,18 @@ func TestSensitiveDataNotLogged_MasterKey(t *testing.T) {
slog.SetDefault(slog.New(slog.DiscardHandler))
})
svc := setupTestIAMService(t)
_, nc, _ := testutil.StartTestJetStream(t)
masterKey, err := GenerateMasterKey()
require.NoError(t, err)
_, err = NewIAMServiceImpl(nc, masterKey, 1)
require.NoError(t, err)
logOutput := buf.String()
masterKeyHex := hex.EncodeToString(svc.masterKey)
masterKeyHex := hex.EncodeToString(masterKey)
assert.NotContains(t, logOutput, masterKeyHex,
"master key hex must not appear in log output")
assert.NotContains(t, logOutput, string(svc.masterKey),
assert.NotContains(t, logOutput, string(masterKey),
"raw master key bytes must not appear in log output")
}
+16 -44
View File
@@ -1,6 +1,10 @@
package handlers_iam
import "encoding/json"
import (
"encoding/json"
"github.com/mulgadc/predastore/pkg/iampolicy"
)
const (
AccessKeyStatusActive = "Active"
@@ -9,8 +13,17 @@ const (
AccountStatusActive = "ACTIVE"
AccountStatusSuspended = "SUSPENDED"
PolicyEffectAllow = "Allow"
PolicyEffectDeny = "Deny"
PolicyEffectAllow = iampolicy.EffectAllow
PolicyEffectDeny = iampolicy.EffectDeny
)
// The IAM policy-document DTOs are owned by predastore's pkg/iampolicy so
// predastore and spinifex share one definition. These aliases keep every
// existing spinifex reference (and its JSON (un)marshalling) working unchanged.
type (
PolicyDocument = iampolicy.PolicyDocument
Statement = iampolicy.Statement
StringOrArr = iampolicy.StringOrArr
)
// User represents an IAM user stored in JetStream KV.
@@ -73,20 +86,6 @@ type Policy struct {
Tags []Tag `json:"tags"`
}
// PolicyDocument is the parsed IAM policy JSON structure.
type PolicyDocument struct {
Version string `json:"Version"`
Statement []Statement `json:"Statement"`
}
// Statement is a single statement within a policy document.
type Statement struct {
Sid string `json:"Sid,omitempty"`
Effect string `json:"Effect"`
Action StringOrArr `json:"Action"`
Resource StringOrArr `json:"Resource"`
}
// Role is an assumable IAM identity stored in JetStream KV.
// AssumeRolePolicyDocument is stored opaque; parsed for shape validation only.
type Role struct {
@@ -138,33 +137,6 @@ type TrustStatement struct {
Condition json.RawMessage `json:"Condition,omitempty"`
}
// StringOrArr handles JSON fields that can be either a string or an array of strings.
type StringOrArr []string
// UnmarshalJSON implements custom unmarshaling for string-or-array fields.
func (s *StringOrArr) UnmarshalJSON(data []byte) error {
var single string
if err := json.Unmarshal(data, &single); err == nil {
*s = []string{single}
return nil
}
var arr []string
if err := json.Unmarshal(data, &arr); err != nil {
return err
}
*s = arr
return nil
}
// MarshalJSON marshals as a string if single element, otherwise as an array.
func (s StringOrArr) MarshalJSON() ([]byte, error) {
if len(s) == 1 {
return json.Marshal(s[0])
}
return json.Marshal([]string(s))
}
// BootstrapData is the on-disk JSON file consumed on first gateway start
// to seed the root IAM user into NATS KV.
type BootstrapData struct {
+2 -2
View File
@@ -23,11 +23,11 @@ func TestStringOrArr_UnmarshalArray(t *testing.T) {
}
func TestStringOrArr_UnmarshalNull(t *testing.T) {
// null JSON unmarshals as empty string via the string path → [""]
// A JSON null yields a nil slice (an inert field), not [""].
var s StringOrArr
err := json.Unmarshal([]byte(`null`), &s)
require.NoError(t, err)
assert.Equal(t, StringOrArr{""}, s)
assert.Nil(t, s)
}
func TestStringOrArr_UnmarshalEmptyArray(t *testing.T) {