s3api: support group inline policies + Condition enforcement (#9569)

* test(s3api): cover IAM inline policy aws:SourceIp + group inline gap

Unit tests under weed/s3api/ drive PutUserPolicy / PutGroupPolicy → reload
→ VerifyActionPermission with a synthetic 127.0.0.1 request and assert that
the policy's IpAddress condition flips the outcome.

The user-policy cases pass on master (hydrateRuntimePolicies already routes
inline docs through the policy engine, so Condition blocks are honored end-
to-end). The group-policy case fails: PutGroupPolicy still returns
NotImplemented, so a group inline doc never lands in the engine.

Integration counterparts live under test/s3/iam/ and exercise the same
paths against a live SeaweedFS S3+IAM endpoint.

* s3api: support group inline policies + Condition enforcement

PutGroupPolicy/GetGroupPolicy/DeleteGroupPolicy/ListGroupPolicies used to
return NotImplemented in embedded IAM mode, so anything attached to a
group as an inline doc — including aws:SourceIp or any other Condition —
was simply unreachable.

Wire the four endpoints to the credential-store methods that were
already in place (memory, postgres, filer_etc all implement
GroupInlinePolicyStore). On every config reload, hydrateRuntimePolicies
now also walks LoadGroupInlinePolicies, registers each doc in the IAM
policy engine under __inline_group_policy__/<group>/<policy>, and
appends that key to Group.PolicyNames so evaluateIAMPolicies picks it up
through its existing group walk. PutGroupPolicy/DeleteGroupPolicy are
added to the ReloadConfiguration trigger list in DoActions.

Side fix: MemoryStore.LoadConfiguration now surfaces store.groups too.
Without it iam.groups never repopulated on a memory-store reload, so
group policy evaluation silently no-op'd whether the policy was inline
or attached. The existing tests didn't notice because no test reloaded
through cm after creating a group.

The NotImplemented unit test is inverted to drive the new round-trip.

* s3api: drop redundant refreshIAMConfiguration from Put/DeleteGroupPolicy

DoActions already triggers ReloadConfiguration for both actions via the
explicit reload list, so calling refreshIAMConfiguration inline runs the
load twice per request. Per PR review.

* s3api: scope group-policy resource names per test; tighten deny polling

- Integration test resource names get a per-test suffix so retried or
  parallel CI jobs don't trip EntityAlreadyExists / BucketAlreadyExists.
- Deny-path Eventually loops gate on AccessDenied via a typed helper
  rather than any non-nil error; transient setup errors no longer end
  the wait prematurely.
- ListGroupPolicies returns ServiceFailure when the credential manager
  is nil, matching Put/Get/DeleteGroupPolicy.

* test(s3 iam): cover both IPv4 and IPv6 loopback in allow CIDRs

CI runners with happy-eyeballs resolve `localhost` to ::1 first, in
which case a 127.0.0.0/8-only allow would silently never match and the
deny-driven enforcement test would hang for the allow case. Add ::1/128
to every loopback-matching policy so the allow path works regardless of
which loopback family the SDK lands on.
This commit is contained in:
Chris Lu authored and GitHub committed 2026-05-19 16:03:45 -07:00
1 parent 77ac781bbd
commit 285025eb73
6 files changed
+809 -61

No files matched your search

+71 -34
View File
@@ -1671,10 +1671,18 @@ type inlinePolicyLoader interface {
LoadInlinePolicies(ctx context.Context) (map[string]map[string]policy_engine.PolicyDocument, error)
}
type groupInlinePolicyLoader interface {
LoadGroupInlinePolicies(ctx context.Context) (map[string]map[string]policy_engine.PolicyDocument, error)
}
func inlinePolicyRuntimeName(userName, policyName string) string {
return "__inline_policy__/" + userName + "/" + policyName
}
func inlineGroupPolicyRuntimeName(groupName, policyName string) string {
return "__inline_group_policy__/" + groupName + "/" + policyName
}
func mergePoliciesIntoConfiguration(config *iam_pb.S3ApiConfiguration, policies []*iam_pb.Policy) {
if len(policies) == 0 {
return
@@ -1759,44 +1767,73 @@ func (iam *IdentityAccessManagement) hydrateRuntimePolicies(ctx context.Context,
return nil
}
inlineLoader, ok := store.(inlinePolicyLoader)
if !ok {
return nil
}
inlinePoliciesByUser, err := inlineLoader.LoadInlinePolicies(ctx)
if err != nil {
return fmt.Errorf("failed to load inline policies for runtime: %w", err)
}
if len(inlinePoliciesByUser) == 0 {
return nil
}
identityByName := make(map[string]*iam_pb.Identity, len(config.Identities))
for _, identity := range config.Identities {
identityByName[identity.Name] = identity
}
inlinePolicies := make([]*iam_pb.Policy, 0)
for userName, userPolicies := range inlinePoliciesByUser {
identity, found := identityByName[userName]
if !found {
continue
}
for policyName, policyDocument := range userPolicies {
content, err := json.Marshal(policyDocument)
if err != nil {
return fmt.Errorf("failed to marshal inline policy %q for user %q: %w", policyName, userName, err)
if inlineLoader, ok := store.(inlinePolicyLoader); ok {
inlinePoliciesByUser, err := inlineLoader.LoadInlinePolicies(ctx)
if err != nil {
return fmt.Errorf("failed to load inline policies for runtime: %w", err)
}
if len(inlinePoliciesByUser) > 0 {
identityByName := make(map[string]*iam_pb.Identity, len(config.Identities))
for _, identity := range config.Identities {
identityByName[identity.Name] = identity
}
runtimePolicyName := inlinePolicyRuntimeName(userName, policyName)
inlinePolicies = append(inlinePolicies, &iam_pb.Policy{
Name: runtimePolicyName,
Content: string(content),
})
identity.PolicyNames = appendUniquePolicyName(identity.PolicyNames, runtimePolicyName)
for userName, userPolicies := range inlinePoliciesByUser {
identity, found := identityByName[userName]
if !found {
continue
}
for policyName, policyDocument := range userPolicies {
content, err := json.Marshal(policyDocument)
if err != nil {
return fmt.Errorf("failed to marshal inline policy %q for user %q: %w", policyName, userName, err)
}
runtimePolicyName := inlinePolicyRuntimeName(userName, policyName)
inlinePolicies = append(inlinePolicies, &iam_pb.Policy{
Name: runtimePolicyName,
Content: string(content),
})
identity.PolicyNames = appendUniquePolicyName(identity.PolicyNames, runtimePolicyName)
}
}
}
}
if groupLoader, ok := store.(groupInlinePolicyLoader); ok {
groupPoliciesByName, err := groupLoader.LoadGroupInlinePolicies(ctx)
if err != nil {
return fmt.Errorf("failed to load group inline policies for runtime: %w", err)
}
if len(groupPoliciesByName) > 0 {
groupByName := make(map[string]*iam_pb.Group, len(config.Groups))
for _, group := range config.Groups {
groupByName[group.Name] = group
}
for groupName, groupPolicies := range groupPoliciesByName {
group, found := groupByName[groupName]
if !found {
continue
}
for policyName, policyDocument := range groupPolicies {
content, err := json.Marshal(policyDocument)
if err != nil {
return fmt.Errorf("failed to marshal inline policy %q for group %q: %w", policyName, groupName, err)
}
runtimePolicyName := inlineGroupPolicyRuntimeName(groupName, policyName)
inlinePolicies = append(inlinePolicies, &iam_pb.Policy{
Name: runtimePolicyName,
Content: string(content),
})
group.PolicyNames = appendUniquePolicyName(group.PolicyNames, runtimePolicyName)
}
}
}
}