mirror of
https://github.com/versity/versitygw.git
synced 2026-09-24 17:04:16 +00:00
feat: add IAM Role CRUD
Adds `CreateRole`, `GetRole`, `ListRoles`, `DeleteRole`, and `UpdateAssumeRolePolicy` to the standalone IAM service, following the same controller/storage patterns established for users. Both the internal filesystem/S3-backed store and the Vault-backed store implement the new `Storer` methods, with role-specific indexing and lookup helpers mirroring the existing user ones. Role creation requires a trust policy, passed as `AssumeRolePolicyDocument`. A trust policy is a distinct kind of IAM policy document that governs who (or what) is allowed to assume a role, rather than what actions the role itself is permitted to perform. Its grammar is effectively the inverse of an identity policy: `Principal` is required, `Action`/`NotAction` values must carry the `sts:` prefix, and `Resource`/`NotResource` are forbidden. This is implemented in `iamapi/policy/trust.go` as a new validation path alongside the existing identity-policy validation, and is reused by `UpdateAssumeRolePolicy` when replacing a role's trust policy. Also fixes user name uniqueness enforcement to be case-insensitive, matching AWS IAM behavior, and applies the same case-insensitive handling to role names. The internal store now maintains lowercase name indexes for both users and roles, and the Vault store resolves the canonical stored key via a case-insensitive list-and-compare fallback since Vault's KV paths are case-sensitive.
This commit is contained in:
@@ -41,6 +41,10 @@ type Statement struct {
|
||||
NotResource StringOrSlice
|
||||
Principal json.RawMessage
|
||||
NotPrincipal json.RawMessage
|
||||
// Condition is never structurally validated (neither the identity- nor
|
||||
// trust-policy path models its grammar) — it is only checked for
|
||||
// presence, by the trust-policy Cognito-provider rule.
|
||||
Condition json.RawMessage
|
||||
}
|
||||
|
||||
// UnmarshalJSON accepts Statement as either a single JSON object or an
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// Copyright 2026 Versity Software
|
||||
// This file is 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 policy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/versity/versitygw/iamapi/iamerr"
|
||||
)
|
||||
|
||||
// trustPrincipalKeys are the only keys IAM accepts inside a trust policy
|
||||
// statement's Principal object. CanonicalUser is deliberately not accepted
|
||||
// here (see errTrustInvalidPrincipalKey) since it identifies an S3 canonical
|
||||
// user id which is the legacy s3 user identifier and is not planned to support
|
||||
var trustPrincipalKeys = map[string]bool{
|
||||
"AWS": true,
|
||||
"Service": true,
|
||||
"Federated": true,
|
||||
}
|
||||
|
||||
const cognitoFederatedProvider = "cognito-identity.amazonaws.com"
|
||||
|
||||
// validServicePrincipals are the only Service principal values the gateway
|
||||
// recognizes. Real AWS validates Service against its live catalog of
|
||||
// ~300+ service principals; the gateway only exposes S3, STS, and IAM
|
||||
// APIs, so those are the only services that could plausibly ever assume a
|
||||
// role here.
|
||||
var validServicePrincipals = map[string]bool{
|
||||
"s3.amazonaws.com": true,
|
||||
"sts.amazonaws.com": true,
|
||||
"iam.amazonaws.com": true,
|
||||
}
|
||||
|
||||
// MaxTrustPolicyBytes is IAM's ACLSizePerRole quota: a role has exactly one
|
||||
// trust policy, so unlike inline identity policies (which sum across all of
|
||||
// a user's/role's named policies) this is a plain length check against the
|
||||
// single AssumeRolePolicyDocument/PolicyDocument value.
|
||||
const MaxTrustPolicyBytes = 2048
|
||||
|
||||
var (
|
||||
errTrustInvalidJSON = iamerr.MalformedPolicyDocument("This policy contains invalid Json")
|
||||
errTrustInvalidVersion = iamerr.MalformedPolicyDocument("The policy must contain a valid version string")
|
||||
errTrustEmptyStatement = iamerr.MalformedPolicyDocument("Could not parse the policy: Statement is empty!")
|
||||
errTrustDuplicateSid = iamerr.MalformedPolicyDocument("The Statement Ids in the policy are not unique")
|
||||
errTrustMissingEffect = iamerr.MalformedPolicyDocument("Missing required field Effect")
|
||||
errTrustMissingPrincipal = iamerr.MalformedPolicyDocument("Missing required field Principal")
|
||||
errTrustEmptyPrincipal = iamerr.MalformedPolicyDocument("Missing required field Principal cannot be empty!")
|
||||
errTrustPrincipalNotObject = iamerr.MalformedPolicyDocument("Principal must be a JSON object.")
|
||||
errTrustAllowNotPrincipal = iamerr.MalformedPolicyDocument("Allow with NotPrincipal is not allowed.")
|
||||
errTrustNotPrincipalForbidden = iamerr.MalformedPolicyDocument("AssumeRole policy must not contain NotPrincipal field.")
|
||||
errTrustMissingAction = iamerr.MalformedPolicyDocument("Missing required field Action")
|
||||
errTrustNonSTSAction = iamerr.MalformedPolicyDocument("AssumeRole policy may only specify STS AssumeRole actions.")
|
||||
errTrustResourceForbidden = iamerr.MalformedPolicyDocument("Has prohibited field Resource")
|
||||
errTrustNotResourceForbidden = iamerr.MalformedPolicyDocument("AssumeRole policy must not contain resources.")
|
||||
errTrustCognitoConditionRequired = iamerr.MalformedPolicyDocument("A condition block must be present for the Cognito provider")
|
||||
errTrustSyntax = iamerr.MalformedPolicyDocument("Syntax error in policy.")
|
||||
)
|
||||
|
||||
// ParseTrust parses raw as an IAM role trust-policy document (the value of
|
||||
// AssumeRolePolicyDocument / UpdateAssumeRolePolicy's PolicyDocument) and
|
||||
// checks it against trust-policy grammar: Principal is required (the
|
||||
// opposite of an identity policy), Action/NotAction values must carry the
|
||||
// "sts:" prefix, and Resource/NotResource are forbidden.
|
||||
func ParseTrust(raw string) error {
|
||||
var doc Document
|
||||
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
|
||||
return errTrustInvalidJSON
|
||||
}
|
||||
return doc.ValidateTrust()
|
||||
}
|
||||
|
||||
// ValidateTrust checks d against IAM's trust-policy document grammar: a
|
||||
// valid Version if present, a non-empty Statement (single object or
|
||||
// array), document-wide unique Sids, and per statement, the rules enforced
|
||||
// by Statement.ValidateTrust.
|
||||
func (d Document) ValidateTrust() error {
|
||||
if d.Version != "" && d.Version != Version2008 && d.Version != Version2012 {
|
||||
return errTrustInvalidVersion
|
||||
}
|
||||
if len(d.Statement) == 0 {
|
||||
return errTrustEmptyStatement
|
||||
}
|
||||
|
||||
seenSids := make(map[string]struct{}, len(d.Statement))
|
||||
for _, stmt := range d.Statement {
|
||||
if err := stmt.ValidateTrust(); err != nil {
|
||||
return err
|
||||
}
|
||||
if stmt.Sid != "" {
|
||||
if _, ok := seenSids[stmt.Sid]; ok {
|
||||
return errTrustDuplicateSid
|
||||
}
|
||||
seenSids[stmt.Sid] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateTrust checks s against IAM trust-policy statement grammar: a
|
||||
// valid Effect, a required Principal (never NotPrincipal), an Action or
|
||||
// NotAction with only "sts:"-prefixed values, and no Resource/NotResource.
|
||||
// Condition is not modeled or validated(not supported at the moment)
|
||||
func (s Statement) ValidateTrust() error {
|
||||
switch s.Effect {
|
||||
case "Allow", "Deny":
|
||||
case "":
|
||||
return errTrustMissingEffect
|
||||
default:
|
||||
return iamerr.MalformedPolicyDocument(fmt.Sprintf("Invalid effect: %s", s.Effect))
|
||||
}
|
||||
|
||||
if len(s.NotPrincipal) > 0 {
|
||||
if s.Effect == "Allow" {
|
||||
return errTrustAllowNotPrincipal
|
||||
}
|
||||
return errTrustNotPrincipalForbidden
|
||||
}
|
||||
if err := s.validateTrustPrincipal(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(s.Action) == 0 && len(s.NotAction) == 0 {
|
||||
return errTrustMissingAction
|
||||
}
|
||||
for _, action := range s.Action {
|
||||
if !strings.HasPrefix(action, "sts:") {
|
||||
return errTrustNonSTSAction
|
||||
}
|
||||
}
|
||||
for _, action := range s.NotAction {
|
||||
if !strings.HasPrefix(action, "sts:") {
|
||||
return errTrustNonSTSAction
|
||||
}
|
||||
}
|
||||
|
||||
if len(s.Resource) > 0 {
|
||||
return errTrustResourceForbidden
|
||||
}
|
||||
if len(s.NotResource) > 0 {
|
||||
return errTrustNotResourceForbidden
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateTrustPrincipal checks s.Principal against trust-policy grammar:
|
||||
// required, a JSON object (not a bare string or array), non-empty, with
|
||||
// only AWS/Service/Federated keys, plus the Cognito-specific Condition
|
||||
// requirement. Real AWS additionally validates that AWS/Service values
|
||||
// resolve to real accounts/services against its live catalog; the gateway
|
||||
// has no such catalog for AWS account/ARN values and validates those shape
|
||||
// only. Service values are the exception — they're checked against
|
||||
// validServicePrincipals, since the gateway only exposes S3, STS, and IAM
|
||||
// APIs and so only those services could ever assume a role here.
|
||||
func (s Statement) validateTrustPrincipal() error {
|
||||
raw := s.Principal
|
||||
if len(raw) == 0 {
|
||||
return errTrustMissingPrincipal
|
||||
}
|
||||
|
||||
var principal map[string]StringOrSlice
|
||||
if err := json.Unmarshal(raw, &principal); err != nil {
|
||||
var asString string
|
||||
if err := json.Unmarshal(raw, &asString); err == nil {
|
||||
return errTrustPrincipalNotObject
|
||||
}
|
||||
return errTrustSyntax
|
||||
}
|
||||
|
||||
if len(principal) == 0 {
|
||||
return errTrustEmptyPrincipal
|
||||
}
|
||||
|
||||
requiresCondition := false
|
||||
for key, values := range principal {
|
||||
if !trustPrincipalKeys[key] {
|
||||
return iamerr.MalformedPolicyDocument(fmt.Sprintf("Invalid principal in policy: %q", key))
|
||||
}
|
||||
if key == "Service" {
|
||||
for _, v := range values {
|
||||
if !validServicePrincipals[v] {
|
||||
return iamerr.MalformedPolicyDocument(fmt.Sprintf("Invalid principal in policy: %q:%q", strings.ToUpper(key), v))
|
||||
}
|
||||
}
|
||||
}
|
||||
if key == "Federated" && slices.Contains(values, cognitoFederatedProvider) {
|
||||
requiresCondition = true
|
||||
}
|
||||
}
|
||||
|
||||
if requiresCondition && len(s.Condition) == 0 {
|
||||
return errTrustCognitoConditionRequired
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright 2026 Versity Software
|
||||
// This file is 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 policy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/versity/versitygw/iamapi/iamerr"
|
||||
)
|
||||
|
||||
// Every case below was verified against a live AWS IAM account, except
|
||||
// where noted as a deliberate simplification (see IAM_ROLES_IMPLEMENTATION_PLAN.md).
|
||||
// The "ec2 service (unsupported)" case is one such deliberate deviation:
|
||||
// real AWS accepts ec2.amazonaws.com, but this gateway only exposes S3,
|
||||
// STS, and IAM APIs, so it restricts Service principals to those three.
|
||||
func TestParseTrust(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
doc string
|
||||
wantErr error // nil means ParseTrust must succeed
|
||||
}{
|
||||
{"valid AWS principal", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::123456789012:root"},"Action":"sts:AssumeRole"}]}`, nil},
|
||||
{"valid without version", `{"Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, nil},
|
||||
{"valid Service principal", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"s3.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, nil},
|
||||
{"valid multiple principal type keys together", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*","Service":"sts.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, nil},
|
||||
{"valid Federated non-cognito provider", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"bogus.example.com"},"Action":"sts:AssumeRole"}]}`, nil},
|
||||
{"valid non-AssumeRole sts action", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:TagSession"}]}`, nil},
|
||||
{"valid NotAction with sts prefix", `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":{"AWS":"*"},"NotAction":"sts:AssumeRole"}]}`, nil},
|
||||
{"valid action array all sts prefixed", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":["sts:AssumeRole","sts:TagSession"]}]}`, nil},
|
||||
{"valid multiple unique sids", `{"Version":"2012-10-17","Statement":[{"Sid":"A","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"},{"Sid":"B","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, nil},
|
||||
|
||||
{"invalid json syntax", `{invalid json`, errTrustInvalidJSON},
|
||||
{"invalid version", `{"Version":"2020-01-01","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, errTrustInvalidVersion},
|
||||
{"empty statement array", `{"Version":"2012-10-17","Statement":[]}`, errTrustEmptyStatement},
|
||||
{"missing statement", `{"Version":"2012-10-17"}`, errTrustEmptyStatement},
|
||||
|
||||
{"invalid effect value", `{"Version":"2012-10-17","Statement":[{"Effect":"Maybe","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("Invalid effect: Maybe")},
|
||||
{"missing effect field", `{"Version":"2012-10-17","Statement":[{"Principal":{"Service":"s3.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, errTrustMissingEffect},
|
||||
|
||||
{"missing principal", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"sts:AssumeRole"}]}`, errTrustMissingPrincipal},
|
||||
{"empty principal object", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{},"Action":"sts:AssumeRole"}]}`, errTrustEmptyPrincipal},
|
||||
{"principal as bare string", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"sts:AssumeRole"}]}`, errTrustPrincipalNotObject},
|
||||
{"principal as array", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":["a"],"Action":"sts:AssumeRole"}]}`, errTrustSyntax},
|
||||
{"principal has invalid key", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"CanonicalUser":"abc"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument(`Invalid principal in policy: "CanonicalUser"`)},
|
||||
{"principal has unrecognized service", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"invalid.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument(`Invalid principal in policy: "SERVICE":"invalid.amazonaws.com"`)},
|
||||
{"principal has ec2 service (unsupported)", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument(`Invalid principal in policy: "SERVICE":"ec2.amazonaws.com"`)},
|
||||
|
||||
{"allow with notprincipal", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotPrincipal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, errTrustAllowNotPrincipal},
|
||||
{"deny with notprincipal", `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","NotPrincipal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, errTrustNotPrincipalForbidden},
|
||||
|
||||
{"missing action and notaction", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"}}]}`, errTrustMissingAction},
|
||||
{"bare wildcard action rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"*"}]}`, errTrustNonSTSAction},
|
||||
{"non-sts vendor action rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"s3:GetObject"}]}`, errTrustNonSTSAction},
|
||||
{"non-sts notaction rejected even on deny", `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":{"AWS":"*"},"NotAction":"s3:GetObject"}]}`, errTrustNonSTSAction},
|
||||
|
||||
{"resource forbidden", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Resource":"*"}]}`, errTrustResourceForbidden},
|
||||
{"notresource forbidden", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","NotResource":"*"}]}`, errTrustNotResourceForbidden},
|
||||
|
||||
{"duplicate sid across statements", `{"Version":"2012-10-17","Statement":[{"Sid":"Dup","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"},{"Sid":"Dup","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, errTrustDuplicateSid},
|
||||
|
||||
{"cognito federated without condition", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"cognito-identity.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, errTrustCognitoConditionRequired},
|
||||
{"cognito federated with condition", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"cognito-identity.amazonaws.com"},"Action":"sts:AssumeRole","Condition":{"StringEquals":{"cognito-identity.amazonaws.com:aud":"us-east-1:abc"}}}]}`, nil},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ParseTrust(tt.doc)
|
||||
if tt.wantErr == nil {
|
||||
if err != nil {
|
||||
t.Fatalf("ParseTrust() = %v, want nil", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("ParseTrust() = %v, want %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user