Skip to content
Merged
29 changes: 21 additions & 8 deletions contrib/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,14 +189,27 @@ actually run the request, so neither the SigV4 signing scope nor a forged
on the source. A DeleteObjects request is checked key by key, and one
denied key denies the whole request (real S3 deletes the allowed keys and
reports the others). Bucket policies are not evaluated yet.
- The other REST services (Lambda, API Gateway, EKS, Route 53, CloudFront and
the rest) are checked at service level for now. A request passes only when
the caller's policies allow every action of that service on every resource,
such as `lambda:*` on `*` or `AdministratorAccess`. **A fine-grained or
resource-scoped policy on one of these services (for example
`lambda:InvokeFunction` on one function) is denied until that service gets
per-operation checks.** A `Deny` that touches the service also denies the
request.
- Lambda is checked per operation on the function ARN, or the version or alias
ARN when the request names one (`function:name:prod`); layers on the layer or
layer version ARN, and event source mappings on their ARN. Lambda condition
keys such as `lambda:FunctionUrlAuthType`, `lambda:Principal`,
`lambda:FunctionArn` and `lambda:Layer`, and the `aws:RequestTag`,
`aws:TagKeys` and `aws:ResourceTag` keys, are set. Creating a function with
tags also needs `lambda:TagResource`, and with layers `lambda:GetLayerVersion`
on each layer version. `Invoke` is also allowed when the function's
resource-based policy names the caller's user or role session ARN. A function
URL with `AWS_IAM` auth needs `lambda:InvokeFunctionUrl` and
`lambda:InvokeFunction`; one with `NONE` auth is called without a signature,
and only when the function's resource-based policy grants both actions to
`*` (as `aws lambda add-permission --principal '*'` does); otherwise it
answers 403 Forbidden.
- The other REST services (API Gateway, EKS, Route 53, CloudFront and the
rest) are checked at service level for now. A request passes only when the
caller's policies allow every action of that service on every resource, such
as `eks:*` on `*` or `AdministratorAccess`. **A fine-grained or
resource-scoped policy on one of these services is denied until that service
gets per-operation checks.** A `Deny` that touches the service also denies
the request.
- The account root and IAM users with no policies are unrestricted, so a
freshly created user can bootstrap others. Role sessions are always
evaluated on the role's policies.
Expand Down
119 changes: 119 additions & 0 deletions contrib/server/enforce_authz_lambda_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package main

import (
"context"
"io"
"net/http"
"net/url"
"strings"
"testing"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/lambda"
lambdatypes "github.com/aws/aws-sdk-go-v2/service/lambda/types"
)

func lambdaClient(t *testing.T, endpoint string, c aws.Credentials) *lambda.Client {
t.Helper()

return lambda.NewFromConfig(credsConfig(t, c), func(o *lambda.Options) { o.BaseEndpoint = aws.String(endpoint) })
}

// urlGet calls a function URL by its host, on the server's listener.
func urlGet(t *testing.T, endpoint, functionURL string) (int, string) {
t.Helper()

u, err := url.Parse(functionURL)
if err != nil {
t.Fatalf("parse %s: %v", functionURL, err)
}

req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, endpoint+"/", http.NoBody)
if err != nil {
t.Fatalf("new request: %v", err)
}

req.Host = u.Host

resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("GET %s: %v", u.Host, err)
}
defer resp.Body.Close()

b, _ := io.ReadAll(resp.Body)

return resp.StatusCode, string(b)
}

// TestEnforceAuthLambda drives the real Lambda SDK against cloudemu serve
// with --enforce-auth: Invoke and the control plane are authorized on the
// function, version or alias ARN, and a NONE function URL is public only once
// its resource-based policy grants public access.
func TestEnforceAuthLambda(t *testing.T) {
endpoint, stop := enforceAuthServer(t)
defer stop()

ctx := context.Background()
boot := clientsFor(t, endpoint, seedBootUser(t, endpoint))
admin := lambdaClient(t, endpoint, boot.cred)

_, err := admin.CreateFunction(ctx, &lambda.CreateFunctionInput{
FunctionName: aws.String("app"), Runtime: lambdatypes.RuntimePython312, Handler: aws.String("index.handler"),
Role: aws.String("arn:aws:iam::000000000000:role/r"), Code: &lambdatypes.FunctionCode{ZipFile: []byte("code")},
Publish: true,
})
wantOK(t, "CreateFunction as boot", err)

_, err = admin.CreateAlias(ctx, &lambda.CreateAliasInput{
FunctionName: aws.String("app"), Name: aws.String("prod"), FunctionVersion: aws.String("1"),
})
wantOK(t, "CreateAlias as boot", err)

got, err := admin.GetFunction(ctx, &lambda.GetFunctionInput{FunctionName: aws.String("app")})
wantOK(t, "GetFunction as boot", err)

arn := aws.ToString(got.Configuration.FunctionArn)
invoker := lambdaClient(t, endpoint, boot.newUser(t, "invoker", `{"Version":"2012-10-17","Statement":[`+
`{"Effect":"Allow","Action":"lambda:InvokeFunction","Resource":"`+arn+`:prod"},`+
`{"Effect":"Allow","Action":"lambda:GetFunction","Resource":"`+arn+`"}]}`))

_, err = invoker.Invoke(ctx, &lambda.InvokeInput{FunctionName: aws.String("app"), Qualifier: aws.String("prod")})
wantOK(t, "Invoke the alias", err)
_, err = invoker.Invoke(ctx, &lambda.InvokeInput{FunctionName: aws.String("app:prod")})
wantOK(t, "Invoke name:alias", err)
_, err = invoker.Invoke(ctx, &lambda.InvokeInput{FunctionName: aws.String("app")})
wantCode(t, "Invoke unqualified", err, "AccessDeniedException")
_, err = invoker.GetFunction(ctx, &lambda.GetFunctionInput{FunctionName: aws.String("app")})
wantOK(t, "GetFunction", err)
_, err = invoker.DeleteFunction(ctx, &lambda.DeleteFunctionInput{FunctionName: aws.String("app")})
wantCode(t, "DeleteFunction", err, "AccessDeniedException")

if !strings.Contains(err.Error(), "lambda:DeleteFunction on resource: "+arn) {
t.Fatalf("deny message = %v", err)
}

u, err := admin.CreateFunctionUrlConfig(ctx, &lambda.CreateFunctionUrlConfigInput{
FunctionName: aws.String("app"), AuthType: lambdatypes.FunctionUrlAuthTypeNone,
})
wantOK(t, "CreateFunctionUrlConfig", err)

if status, body := urlGet(t, endpoint, aws.ToString(u.FunctionUrl)); status != http.StatusForbidden {
t.Fatalf("NONE URL without a public grant: %d %s, want 403", status, body)
}

_, err = admin.AddPermission(ctx, &lambda.AddPermissionInput{
FunctionName: aws.String("app"), StatementId: aws.String("url"), Action: aws.String("lambda:InvokeFunctionUrl"),
Principal: aws.String("*"), FunctionUrlAuthType: lambdatypes.FunctionUrlAuthTypeNone,
})
wantOK(t, "AddPermission InvokeFunctionUrl", err)
_, err = admin.AddPermission(ctx, &lambda.AddPermissionInput{
FunctionName: aws.String("app"), StatementId: aws.String("invoke"), Action: aws.String("lambda:InvokeFunction"),
Principal: aws.String("*"), InvokedViaFunctionUrl: aws.Bool(true),
})
wantOK(t, "AddPermission InvokeFunction", err)

if status, body := urlGet(t, endpoint, aws.ToString(u.FunctionUrl)); status != http.StatusOK {
t.Fatalf("NONE URL with the public grant: %d %s, want 200", status, body)
}
}
1 change: 1 addition & 0 deletions contrib/server/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/ec2 v1.322.0
github.com/aws/aws-sdk-go-v2/service/elasticache v1.55.0
github.com/aws/aws-sdk-go-v2/service/iam v1.53.10
github.com/aws/aws-sdk-go-v2/service/lambda v1.90.1
github.com/aws/aws-sdk-go-v2/service/rds v1.124.4
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.3
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.27
Expand Down
2 changes: 1 addition & 1 deletion providers/aws/lambda/layer_permissions.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ func layerStatementJSON(s driver.LayerPermissionStatement, resource string) map[

if s.OrganizationID != "" {
stmt["Condition"] = map[string]any{
"StringEquals": map[string]string{"aws:PrincipalOrgID": s.OrganizationID},
"StringEquals": map[string]string{condPrincipalOrgID: s.OrganizationID},
}
}

Expand Down
100 changes: 93 additions & 7 deletions providers/aws/lambda/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import (
// principal renders as the account's IAM root ARN.
const accountIDLen = 12

// condPrincipalOrgID is the condition key of an organization-scoped grant.
const condPrincipalOrgID = "aws:PrincipalOrgID"

// policyKey normalizes a Qualifier into the resource-policy map key. AWS keeps a
// separate policy per version/alias; an empty or "$LATEST" qualifier is the
// function's unqualified policy.
Expand All @@ -28,13 +31,28 @@ func policyKey(qualifier string) string {
// backs Terraform's aws_lambda_permission and the grants S3/SNS/EventBridge
// create to invoke a function. A Qualifier scopes the statement to a single
// published version or alias. AWS stores a separate policy per qualifier. The
// emulator stores statements without evaluating them. Invocation is never
// actually denied.
// statements are evaluated by the wire server's Lambda handler, and only under
// EnforceAuth (see PolicyStatements).
func (m *Mock) AddPermission(_ context.Context, functionName, qualifier string, stmt driver.PermissionStatement) error {
if stmt.StatementID == "" {
return cerrors.New(cerrors.InvalidArgument, "StatementId is required")
}

// "Lambda does not support adding policies to version $LATEST"
// (AddPermission API reference): the unqualified function's policy is
// written without a qualifier.
if qualifier == latestVersion {
return cerrors.New(cerrors.InvalidArgument, "We currently do not support adding policies for $LATEST.")
}

switch stmt.FunctionURLAuthType {
case "", defaultAuthType, authTypeAWSIAM:
default:
return cerrors.Newf(cerrors.InvalidArgument,
"1 validation error detected: Value '%s' at 'functionUrlAuthType' failed to satisfy constraint: "+
"Member must satisfy enum value set: [AWS_IAM, NONE]", stmt.FunctionURLAuthType)
}

m.mu.Lock()
defer m.mu.Unlock()

Expand Down Expand Up @@ -72,6 +90,13 @@ func (m *Mock) AddPermission(_ context.Context, functionName, qualifier string,
// RemovePermission drops a statement from a function's (qualifier-scoped)
// resource-based policy.
func (m *Mock) RemovePermission(_ context.Context, functionName, qualifier, statementID string) error {
// $LATEST has no policy of its own (AddPermission refuses it), so a
// $LATEST-qualified remove is refused too rather than editing the
// unqualified function's policy.
if qualifier == latestVersion {
return cerrors.New(cerrors.InvalidArgument, "We currently do not support removing policies for $LATEST.")
}

m.mu.Lock()
defer m.mu.Unlock()

Expand Down Expand Up @@ -119,7 +144,7 @@ func (m *Mock) GetPolicy(_ context.Context, functionName, qualifier string) (str

statements := make([]map[string]any, 0, len(scoped))
for _, s := range scoped {
statements = append(statements, statementJSON(s, resource))
statements = append(statements, statementJSON(&s, resource))
}

doc, err := json.Marshal(map[string]any{
Expand All @@ -136,7 +161,7 @@ func (m *Mock) GetPolicy(_ context.Context, functionName, qualifier string) (str

// statementJSON renders one resource-policy statement in the IAM shape AWS
// returns, with the principal classified by type (see principalJSON).
func statementJSON(s driver.PermissionStatement, resource string) map[string]any {
func statementJSON(s *driver.PermissionStatement, resource string) map[string]any {
stmt := map[string]any{
"Sid": s.StatementID,
"Effect": "Allow",
Expand All @@ -145,13 +170,74 @@ func statementJSON(s driver.PermissionStatement, resource string) map[string]any
"Resource": resource,
}

if cond := statementCondition(s); len(cond) > 0 {
stmt["Condition"] = cond
}

return stmt
}

// statementCondition renders the AddPermission options of s as the Condition
// block AWS stores for them: ArnLike AWS:SourceArn; StringEquals on
// AWS:SourceAccount, aws:PrincipalOrgID, lambda:EventSourceToken and
// lambda:FunctionUrlAuthType; Bool lambda:InvokedViaFunctionUrl.
func statementCondition(s *driver.PermissionStatement) map[string]any {
cond := map[string]any{}

if s.SourceARN != "" {
stmt["Condition"] = map[string]any{
"ArnLike": map[string]string{"AWS:SourceArn": s.SourceARN},
cond["ArnLike"] = map[string]string{"AWS:SourceArn": s.SourceARN}
}

equals := map[string]string{}

for key, value := range map[string]string{
"AWS:SourceAccount": s.SourceAccount,
condPrincipalOrgID: s.PrincipalOrgID,
"lambda:EventSourceToken": s.EventSourceToken,
"lambda:FunctionUrlAuthType": s.FunctionURLAuthType,
} {
if value != "" {
equals[key] = value
}
}

return stmt
if len(equals) > 0 {
cond["StringEquals"] = equals
}

if s.InvokedViaFunctionURL {
cond["Bool"] = map[string]string{"lambda:InvokedViaFunctionUrl": "true"}
}

return cond
}

// PolicyStatements returns the statements of the function's resource-based
// policy for qualifier, and the function ARN that policy applies to (the
// qualified ARN for a version or alias). A qualifier with no policy returns no
// statements; a missing function is NotFound.
func (m *Mock) PolicyStatements(_ context.Context, functionName, qualifier string) (string, []driver.PermissionStatement, error) {
m.mu.Lock()
defer m.mu.Unlock()

fd, ok := m.funcs.Get(functionName)
if !ok {
return "", nil, cerrors.Newf(cerrors.NotFound, "function %s not found", functionName)
}

key := policyKey(qualifier)

resource := fd.info.ARN
if key != latestVersion {
resource += ":" + key
}

stmts := make([]driver.PermissionStatement, 0, len(fd.policies[key]))
for sid := range fd.policies[key] {
stmts = append(stmts, fd.policies[key][sid])
}

return resource, stmts, nil
}

// principalJSON renders an AddPermission Principal in the shape AWS uses in the
Expand Down
71 changes: 71 additions & 0 deletions providers/aws/lambda/policy_conditions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package lambda

import (
"context"
"encoding/json"
"testing"

"github.com/stackshy/cloudemu/v2/services/serverless/driver"
)

// TestAddPermissionConditions checks every AddPermission option is kept and
// rendered as the Condition AWS returns from GetPolicy.
func TestAddPermissionConditions(t *testing.T) {
ctx := context.Background()
m := newTestMock()

_, err := m.CreateFunction(ctx, defaultFuncConfig())
requireNoError(t, err)

requireNoError(t, m.AddPermission(ctx, "my-func", "", driver.PermissionStatement{
StatementID: "url", Action: "lambda:InvokeFunctionUrl", Principal: "*", FunctionURLAuthType: "NONE",
}))
requireNoError(t, m.AddPermission(ctx, "my-func", "", driver.PermissionStatement{
StatementID: "via", Action: "lambda:InvokeFunction", Principal: "*", InvokedViaFunctionURL: true,
}))
requireNoError(t, m.AddPermission(ctx, "my-func", "", driver.PermissionStatement{
StatementID: "s3", Action: "lambda:InvokeFunction", Principal: "s3.amazonaws.com",
SourceARN: "arn:aws:s3:::b", SourceAccount: "123456789012", PrincipalOrgID: "o-abc", EventSourceToken: "tok",
}))

err = m.AddPermission(ctx, "my-func", "", driver.PermissionStatement{
StatementID: "bad", Action: "lambda:InvokeFunctionUrl", Principal: "*", FunctionURLAuthType: "BASIC",
})
assertError(t, err, true)

doc, err := m.GetPolicy(ctx, "my-func", "")
requireNoError(t, err)

var policy struct {
Statement []struct {
Sid string
Condition map[string]map[string]string
}
}

requireNoError(t, json.Unmarshal([]byte(doc), &policy))

got := map[string]map[string]map[string]string{}
for _, s := range policy.Statement {
got[s.Sid] = s.Condition
}

assertEqual(t, "NONE", got["url"]["StringEquals"]["lambda:FunctionUrlAuthType"])
assertEqual(t, "true", got["via"]["Bool"]["lambda:InvokedViaFunctionUrl"])
assertEqual(t, "arn:aws:s3:::b", got["s3"]["ArnLike"]["AWS:SourceArn"])
assertEqual(t, "123456789012", got["s3"]["StringEquals"]["AWS:SourceAccount"])
assertEqual(t, "o-abc", got["s3"]["StringEquals"]["aws:PrincipalOrgID"])
assertEqual(t, "tok", got["s3"]["StringEquals"]["lambda:EventSourceToken"])

resource, stmts, err := m.PolicyStatements(ctx, "my-func", "")
requireNoError(t, err)
assertEqual(t, 3, len(stmts))
assertNotEmpty(t, resource)

_, stmts, err = m.PolicyStatements(ctx, "my-func", "prod")
requireNoError(t, err)
assertEqual(t, 0, len(stmts))

_, _, err = m.PolicyStatements(ctx, "missing", "")
assertError(t, err, true)
}
Loading
Loading