Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

More policy tests #56

Merged
merged 9 commits into from
Feb 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions .github/workflows/pull-request-extra-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,46 @@ on:
types: [opened, synchronize, reopened]

jobs:
run_extra_tests:
# tests that we are not debugging at the moment,
# tests that can run in parallel with pytest-xdist
# it's ok to run only on br-ne1
extra_tests_dist:
strategy:
fail-fast: false
matrix:
category:
- policy
- acl
config:
- "../params/br-ne1.yaml"
uses: ./.github/workflows/run-tests.yml
with:
config: "${{ matrix.config }}"
flags: "-v -n auto --color yes -m '${{ matrix.category }}'"
# runner: "self-hosted"
secrets:
PROFILES: ${{ secrets.PROFILES }}

# tests that we want to see all logging messages
# tests that we want to test on both regions
extra_tests_debug:
strategy:
fail-fast: false
matrix:
category:
- bucket_versioning
config:
- "../params/br-ne1.yaml"
- "../params/br-se1.yaml"
uses: ./.github/workflows/run-tests.yml
with:
tests: "*_test.py"
config: "${{ matrix.config }}"
# no multiple workers here just to make it easier to audit the logs (more verbosity with INFO level)
flags: "-v --log-cli-level INFO --color yes -m '${{ matrix.category }}'"
secrets:
PROFILES: ${{ secrets.PROFILES }}

cleanup_tests:
needs: [run_extra_tests]
needs: [extra_tests_debug, extra_tests_dist]
if: always()
uses: ./.github/workflows/cleanup-tests.yml
secrets:
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: Run Tests
on:
workflow_call:
inputs:
tests: { required: false, type: string }
tests: { required: false, type: string, default: "*_test.py"}
config: { required: true, type: string }
flags: { required: true, type: string }
runner: { required: false, type: string, default: "ubuntu-24.04" }
Expand Down Expand Up @@ -53,4 +53,5 @@ jobs:
- name: Run tests ${{ inputs.tests }}
run: |
cd docs
sha256sum $(which mgc rclone aws)
uv run pytest --config ${{ inputs.config }} ${{ inputs.tests }} ${{ inputs.flags }}
37 changes: 26 additions & 11 deletions docs/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@
get_tenants,
replace_failed_put_without_version,
put_object_lock_configuration_with_determination,
get_policy_with_determination,
probe_versioning_status,
delete_all_objects_and_wait,
)
from datetime import datetime, timedelta
from botocore.exceptions import ClientError
Expand Down Expand Up @@ -49,6 +51,10 @@ def default_profile(test_params):
def lock_mode(default_profile):
return default_profile.get("lock_mode", "COMPLIANCE")

@pytest.fixture
def policy_wait_time(default_profile):
return default_profile.get("policy_wait_time", 0)

@pytest.fixture
def profile_name(default_profile):
return (
Expand Down Expand Up @@ -119,15 +125,17 @@ def existing_bucket_name(s3_client):

# Yield the existing bucket name to the test
yield bucket_name

objects = s3_client.list_objects(Bucket=bucket_name).get("Contents", [])

if objects:
for obj in objects:
s3_client.delete_object(Bucket=bucket_name, Key=obj["Key"])

# Teardown: delete the bucket after the test
delete_bucket_and_wait(s3_client, bucket_name)
# Teardown
try:
# Remove policy if present
response = s3_client.delete_bucket_policy(Bucket=bucket_name)
logging.info(f"delete_bucket_policy response:{response}")

delete_all_objects_and_wait(s3_client, bucket_name)
delete_bucket_and_wait(s3_client, bucket_name)
except Exception as e:
logging.error(f"existing_bucket_name teardown failed: {e}")

@pytest.fixture
def create_multipart_object_files():
Expand Down Expand Up @@ -378,7 +386,7 @@ def bucket_with_lock_and_object(s3_client, bucket_with_lock):
return bucket_name, object_key, object_version

@pytest.fixture
def bucket_with_one_object_policy(multiple_s3_clients, request):
def bucket_with_one_object_policy(multiple_s3_clients, policy_wait_time, request):
"""
Prepares an S3 bucket with object and defines its object policies.

Expand All @@ -402,12 +410,19 @@ def bucket_with_one_object_policy(multiple_s3_clients, request):

policy = change_policies_json(bucket=bucket_name, policy_args=request.param, tenants=tenants)
client.put_bucket_policy(Bucket=bucket_name, Policy = policy)


# TODO: HACK: #notcool #eventual-consistency wait for policy to be there
registered_policy = get_policy_with_determination(client, bucket_name)
logging.info(f"Registered policy after (get policy) consistency: {registered_policy}")
wait_time = policy_wait_time
logging.info(f"Receiving, 5 positive GETs is not a guarantee that the policy is in place. Wait more {wait_time} seconds")
time.sleep(wait_time)

# Yield the bucket name and object key to the test
yield bucket_name, object_key

# Teardown: delete the bucket after the test
delete_policy_and_bucket_and_wait(client, bucket_name, request)
delete_policy_and_bucket_and_wait(client, bucket_name, policy_wait_time, request)



Expand Down
167 changes: 119 additions & 48 deletions docs/policies_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,57 @@
# language_info:
# name: python
# ---

# # Bucket Policy (política de bucket)
#
# Buckets de armazenamento por padrão são acessíveis apenas pela dona da conta que criou este
# recurso. Também por padrão, esta dona possui a permissão de executar **qualquer** operação
# neste bucket e nos seus objetos. Configurar uma política de bucket é uma maneira de
# modificar estas permissões padrões, seja para **restringir** de forma granular o número de operações
# que podem ser executadas em um bucket ou objeto, e por quais contas (_"Principals"_, no sentido
# de beneficiários, outorgados), seja para **conceder** mais acessos a determinados recursos, e
# para quais contas.

# + tags=["parameters"]
config = "../params/br-ne1.yaml"
# -

# + {"jupyter": {"source_hidden": true}}
import os
import pytest
import logging
from datetime import datetime, timedelta, timezone
from botocore.exceptions import ClientError
from s3_helpers import(
run_example,
change_policies_json,
)

# # Policy Tests
config = os.getenv("CONFIG", config)
pytestmark = pytest.mark.policy
# -

# Políticas de bucket são descritas por meio de arquivos no formato JSON, que seguem uma gramática
# específica, devem conter uma lista de regras `Statement` onde cada ítem desta lista descreve um
# `Effect` (`Allow` ou `Deny`), um campo `Principal` para descrever a(s) beneficiária(s) da regra
# um campo `Action` com as operações que esta regra permite ou nega e um campo `Resource`, que
# define a qual recurso esta regra se aplicará. As regras desta sintaxe podem ser consultadas no
# documento [Estrutura de uma Bucket Policy](https://docs.magalu.cloud/docs/storage/object-storage/access-control/bucket_policy_overview#estrutura-de-uma-bucket-policy)
# Abaixo um modelo de documento de política sem os campos preenchidos:

# ### Test Variables
policy_dict_template = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "",
"Principal": "",
"Action": "",
"Resource": ""
}
]
}

# + {"jupyter": {"source_hidden": true}}
# Exemplos de documentos inválidos:
malformed_policy_json ='''{
"Version": "2012-10-18",
"Statement": [
Expand All @@ -42,7 +82,6 @@
]
}
"""

wrong_version_policy = """{
"Version": "2012-10-18",
"Statement":
Expand All @@ -56,89 +95,120 @@
}
"""

policy_dict_template = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "",
"Principal": "",
"Action": "",
"Resource": ""
}
]
}


cases = [
*[(case, "MalformedJSON") for case in ['', 'jason',"''", misspeled_policy, wrong_version_policy]],
*[(case, "MalformedPolicy") for case in ['{}','""', malformed_policy_json]],
]
# -

@pytest.mark.parametrize('input, expected_error', cases)
# ## Atribuindo uma política a um bucket usando Python
#
# O método para atribuir uma _bucket policy_ na biblioteca boto3 é o
# [put_bucket_policy](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3/client/put_bucket_policy.html),
# se o documento de política for válido o retorno trará um HTTPStatusCode `204` enquanto
# se o documento for inválido por algum motivo uma _exception_ do tipo `ClientError` será levantada,
# como mostram os testes abaixo:

# ## Asserting the possible combinations that raise errors on put_bucket_policy
@pytest.mark.parametrize('input, expected_error', cases)
def test_put_invalid_bucket_policy(s3_client, existing_bucket_name, input, expected_error):
try:
s3_client.put_bucket_policy(Bucket=existing_bucket_name, Policy=input)
pytest.fail("Expected exception not raised")
except ClientError as e:
# Assert the error code matches the expected one
assert e.response['Error']['Code'] == expected_error


@pytest.mark.parametrize('policies_args', [
{"policy_dict": policy_dict_template, "actions": "s3:PutObject", "effect": "Deny"},
{"policy_dict": policy_dict_template, "actions": "s3:GetObject", "effect": "Deny"},
{"policy_dict": policy_dict_template, "actions": "s3:DeleteObject", "effect": "Deny"}
])
# ## Base case of putting a policy into a bcuket
run_example(__name__, "test_put_invalid_bucket_policy", config=config)

test_cases_actions = [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject",
"s3:GetBucketObjectLockConfiguration",
"s3:GetObjectRetention",
"s3:PutBucketObjectLockConfiguration",
"s3:PutObjectRetention",
]
test_cases_parameters = [
{"policy_dict": policy_dict_template, "actions": action, "effect": "Deny"}
for action in test_cases_actions
]
@pytest.mark.parametrize('policies_args', test_cases_parameters, ids=test_cases_actions)
def test_setup_policies(s3_client, existing_bucket_name, policies_args):
bucket_name = existing_bucket_name

#given a existent and valid bucket
policies = change_policies_json(existing_bucket_name, policies_args, "*")
response = s3_client.put_bucket_policy(Bucket=bucket_name, Policy=policies)
assert response['ResponseMetadata']['HTTPStatusCode'] == 204

# # Tests related to actions on the bucket
# fill up the policy template with the parametrized Action and Effect
policy_doc = change_policies_json(existing_bucket_name, policies_args, "*")

number_clients = 2
logging.info(f"put_bucket_policy: {policy_doc}")
response = s3_client.put_bucket_policy(Bucket=bucket_name, Policy=policy_doc)
assert response['ResponseMetadata']['HTTPStatusCode'] == 204
run_example(__name__, "test_setup_policies", config=config)

@pytest.mark.parametrize('multiple_s3_clients, bucket_with_one_object_policy, boto3_action', [
({"number_clients": number_clients}, {"policy_dict": policy_dict_template, "actions": "s3:PutObject", "effect": "Deny"}, 'put_object'),
({"number_clients": number_clients}, {"policy_dict": policy_dict_template, "actions": "s3:GetObject", "effect": "Deny"}, 'get_object'),
({"number_clients": number_clients}, {"policy_dict": policy_dict_template, "actions": "s3:DeleteObject", "effect": "Deny"}, 'delete_object')
], indirect = ['multiple_s3_clients', 'bucket_with_one_object_policy'])
# ## Negar operações específicas em objetos
#
# Regras com o `Effect` `Deny` impedem que determinadas operações sejam executadas, uma conta
# que sem a política poderia realizar estas operações (exemplos: put_object, delete_object,
# get_object), quando executadas num objeto que é alvo de uma política contendo o `Effect` `Deny`,
# falham com o erro `AccessDeniedByPolicy`, como demonstra o teste a seguir:

# ## Asserting if the owner has permissions blocked from own bucket
number_clients = 2
test_cases_actions_and_methods = [
{"action": "s3:PutObject", "boto3_action": "put_object"},
{"action": "s3:GetObject", "boto3_action": "get_object"},
{"action": "s3:DeleteObject", "boto3_action": "delete_object"},
]
test_cases = [
({"number_clients": number_clients}, {"policy_dict": policy_dict_template, "actions": item["action"], "effect": "Deny"}, item["boto3_action"])
for item in test_cases_actions_and_methods
]
@pytest.mark.parametrize(
'multiple_s3_clients, bucket_with_one_object_policy, boto3_action',
test_cases,
indirect = ['multiple_s3_clients', 'bucket_with_one_object_policy'],
ids = [f"{item['action']},{item['boto3_action']}" for item in test_cases_actions_and_methods],
)
def test_denied_policy_operations_by_owner(s3_client, bucket_with_one_object_policy, boto3_action):
bucket_name, object_key = bucket_with_one_object_policy
kwargs = {
'Bucket': bucket_name, # Set 'Bucket' value from the variable
'Key': object_key
}

#PutObject needs another variable
# PutObject expects a Body argument
if boto3_action == 'put_object' :
kwargs['Body'] = 'The answer for everthong is 42'

kwargs['Body'] = 'The answer for everthing is 42'

# put_object_retention expects a Retention argument
if boto3_action == 'put_object_retention' :
kwargs['Retention'] = {
"Mode": "COMPLIANCE",
"RetainUntilDate": (datetime.now(timezone.utc) + timedelta(days=1)).strftime("%Y-%m-%d")
}

#retrieve the method passed as argument
logging.info(f"call boto3_action:{boto3_action}, with args:{kwargs}")
method = getattr(s3_client, boto3_action)
try:
method(**kwargs)
logging.info(method)
response = method(**kwargs)
logging.info(f"Method response:{response}")
pytest.fail("Expected exception not raised")
except ClientError as e:
logging.info(f"Method error response:{e.response}")
assert e.response['Error']['Code'] == 'AccessDeniedByBucketPolicy'
run_example(__name__, "test_denied_policy_operations_by_owner", config=config)

# ## Permitir operações específicas em objetos
#
# Da mesma forma, uma política pode dar um acesso a uma conta para determinadas operações. Uma
# conta que sem política normalmente seria barrada com um erro `403` para operações como put_object,
# get_object, delete_object, etc, por meio de uma política com `Effect` `Allow` conseguem obter
# sucesso (status `200`, `204` e similares) como demostra o teste abaixo:

@pytest.mark.parametrize('multiple_s3_clients, bucket_with_one_object_policy, boto3_action, expected', [
({"number_clients": number_clients}, {"policy_dict": policy_dict_template, "actions": "s3:PutObject", "effect": "Allow", "Principal": "*"}, 'put_object', 200),
({"number_clients": number_clients}, {"policy_dict": policy_dict_template, "actions": "s3:GetObject", "effect": "Allow", "Principal": "*"}, 'get_object', 200),
({"number_clients": number_clients}, {"policy_dict": policy_dict_template, "actions": "s3:DeleteObject", "effect": "Allow", "Principal": "*"}, 'delete_object', 204)
], indirect = ['multiple_s3_clients', 'bucket_with_one_object_policy'])

# ## Asserting if the owner has permissions allowed from own bucket
def test_allow_policy_operations_by_owner(multiple_s3_clients, bucket_with_one_object_policy, boto3_action,expected):
bucket_name, object_key = bucket_with_one_object_policy

Expand All @@ -149,9 +219,10 @@ def test_allow_policy_operations_by_owner(multiple_s3_clients, bucket_with_one_o

#PutObject needs another variable
if boto3_action == 'put_object' :
kwargs['Body'] = 'The answer for everthong is 42'
kwargs['Body'] = 'The answer for everthing is 42'

#retrieve the method passed as argument
method = getattr(multiple_s3_clients[0], boto3_action)
response = method(**kwargs)
assert response['ResponseMetadata']['HTTPStatusCode'] == expected
run_example(__name__, "test_allow_policy_operations_by_owner", config=config)
Loading