Skip to content
Open
83 changes: 82 additions & 1 deletion backend/api/signals.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
import logging
import time
import weakref

from allauth.account.signals import user_signed_up
from django.db import transaction
from django.db.models.signals import pre_delete
from django.dispatch import receiver
from django.conf import settings
from backend.api.notifier import notify_slack
from api.models import RotatingSecret, RotatingSecretCredential
from api.models import DynamicSecretLease, RotatingSecret, RotatingSecretCredential

logger = logging.getLogger(__name__)

# Keeps one slow or unreachable provider from pushing a delete past worker timeouts.
CASCADE_REVOKE_BUDGET_SECONDS = 20
_cascade_revoke_state = {}

CLOUD_HOSTED = settings.APP_HOST == "cloud"

Expand Down Expand Up @@ -50,3 +61,73 @@ def _rotating_secret_pre_delete(sender, instance, **kwargs):
revoke_credential(cred.id, immediate=True)
except Exception:
pass


def _cascade_revoke_budget(origin):
# One budget per delete attempt: a retry has a new origin object or transaction.
atomic_blocks = transaction.get_connection().atomic_blocks
outermost = atomic_blocks[0] if atomic_blocks else None
owners = [owner for owner in (origin, outermost) if owner is not None]
key = tuple(id(owner) for owner in owners)
state = _cascade_revoke_state.get(key) if owners else None
if state is None:
state = {
"deadline": time.monotonic() + CASCADE_REVOKE_BUDGET_SECONDS,
"unreachable_authentication_ids": set(),
}
try:
for owner in owners:
weakref.finalize(owner, _cascade_revoke_state.pop, key, None)
except TypeError:
return state
if owners:
_cascade_revoke_state[key] = state
return state


def _log_unrevoked_lease(lease, reason, exc_info=False):
from ee.integrations.secrets.dynamic.utils import lease_iam_username

logger.error(
"Dynamic secret lease %s was not revoked during cascade delete (%s); "
"delete IAM user %s manually (secret %s, environment %s, credential %s, "
"expires %s)",
lease.id,
reason,
lease_iam_username(lease) or "<unknown>",
lease.secret_id,
Comment thread
rohan-chaturvedi marked this conversation as resolved.
Dismissed
lease.secret.environment_id,
lease.secret.authentication_id,
lease.expires_at,
exc_info=exc_info,
)


@receiver(pre_delete, sender=DynamicSecretLease)
def _dynamic_secret_lease_pre_delete(sender, instance, origin=None, **kwargs):
# Cascades skip DynamicSecret.delete(); a queued revoke job would find no row.
if instance.status != DynamicSecretLease.ACTIVE:
return

from ee.integrations.secrets.dynamic.utils import (
is_provider_unreachable_error,
revoke_lease_immediately,
)

budget = _cascade_revoke_budget(origin)
authentication_id = instance.secret.authentication_id
if time.monotonic() > budget["deadline"]:
_log_unrevoked_lease(instance, "revocation time budget exhausted")
return
if authentication_id in budget["unreachable_authentication_ids"]:
_log_unrevoked_lease(instance, "provider unreachable earlier in this delete")
return

try:
# Savepoint so a swallowed DB error can't abort the cascade transaction.
with transaction.atomic():
revoke_lease_immediately(instance)
except Exception as exc:
if is_provider_unreachable_error(exc):
budget["unreachable_authentication_ids"].add(authentication_id)
_log_unrevoked_lease(instance, "revocation failed", exc_info=True)
23 changes: 23 additions & 0 deletions backend/api/utils/access/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,29 @@ def user_can_access_environment(user_id, env_id):
).exists()


def accessible_environment_ids(user_id, **env_filters):
"""Env ids the user holds keys for β€” one query instead of one per env."""
EnvironmentKey = apps.get_model("api", "EnvironmentKey")

return set(
EnvironmentKey.objects.filter(
user__user_id=user_id,
user__deleted_at=None,
deleted_at=None,
**env_filters,
).values_list("environment_id", flat=True)
)


def request_accessible_env_ids(info):
"""Caller's env keys, memoized per request β€” nested fields resolve per row."""
env_ids = getattr(info.context, "_accessible_env_ids", None)
if env_ids is None:
env_ids = accessible_environment_ids(info.context.user.userId)
setattr(info.context, "_accessible_env_ids", env_ids)
return env_ids


def service_account_can_access_environment(account_id, env_id):
Environment = apps.get_model("api", "Environment")
EnvironmentKey = apps.get_model("api", "EnvironmentKey")
Expand Down
32 changes: 28 additions & 4 deletions backend/api/views/secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
)
from ee.integrations.secrets.dynamic.serializers import DynamicSecretSerializer
from ee.integrations.secrets.dynamic.utils import (
LEASE_CREATE_PERMISSION_ERROR,
can_create_dynamic_secret_lease,
create_dynamic_secret_lease,
)
from rest_framework.views import APIView
Expand Down Expand Up @@ -278,9 +280,20 @@ def get(self, request, *args, **kwargs):
service_account = request.auth["service_account_token"].service_account

if include_lease:
dynamic_secrets = list(dynamic_secrets_qs)
# The CLI always requests leases; only deny when one would be minted.
if dynamic_secrets and not can_create_dynamic_secret_lease(
env,
organisation_member=request.auth.get("org_member"),
service_account=service_account,
):
return Response(
{"error": LEASE_CREATE_PERMISSION_ERROR}, status=403
)

leases_by_secret_id = {}
failed_leases = []
for ds in dynamic_secrets_qs:
for ds in dynamic_secrets:
try:
lease, _ = create_dynamic_secret_lease(
ds,
Expand Down Expand Up @@ -334,7 +347,7 @@ def get(self, request, *args, **kwargs):
"lease_id": leases_by_secret_id.get(ds.id),
},
).data
for ds in dynamic_secrets_qs
for ds in dynamic_secrets
]
else:
# Serialize without lease
Expand Down Expand Up @@ -811,9 +824,20 @@ def get(self, request, *args, **kwargs):
service_account = request.auth["service_account_token"].service_account

if include_lease:
dynamic_secrets = list(dynamic_secrets_qs)
# The CLI always requests leases; only deny when one would be minted.
if dynamic_secrets and not can_create_dynamic_secret_lease(
env,
organisation_member=request.auth.get("org_member"),
service_account=service_account,
):
return Response(
{"error": LEASE_CREATE_PERMISSION_ERROR}, status=403
)

leases_by_secret_id = {}
failed_leases = []
for ds in dynamic_secrets_qs:
for ds in dynamic_secrets:
try:
lease, _ = create_dynamic_secret_lease(
ds,
Expand Down Expand Up @@ -875,7 +899,7 @@ def get(self, request, *args, **kwargs):
"lease_id": leases_by_secret_id.get(ds.id),
},
).data
for ds in dynamic_secrets_qs
for ds in dynamic_secrets
]
else:
# Serialize without lease
Expand Down
32 changes: 2 additions & 30 deletions backend/backend/graphene/mutations/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,44 +77,16 @@ def revoke_lease_now(lease):
deletes the row leaves the scheduled job re-fetching a gone id, leaking
the provider credential forever.
"""
import django_rq

from ee.integrations.secrets.dynamic.exceptions import LeaseAlreadyRevokedError

if lease.secret.provider != "aws":
logger.warning(
"Unknown dynamic secret provider %s for lease %s β€” skipping revoke",
lease.secret.provider,
lease.id,
)
return

from ee.integrations.secrets.dynamic.aws.utils import (
revoke_aws_dynamic_secret_lease,
)
from ee.integrations.secrets.dynamic.utils import revoke_lease_immediately

try:
revoke_aws_dynamic_secret_lease(lease.id, manual=True)
except LeaseAlreadyRevokedError:
pass # idempotent retry
revoke_lease_immediately(lease)
except Exception:
logger.exception("Failed to revoke dynamic secret lease %s", lease.id)
raise GraphQLError(
"Failed to revoke active dynamic credentials. Please try again."
)

if lease.cleanup_job_id:
try:
scheduler = django_rq.get_scheduler("scheduled-jobs")
scheduler.cancel(lease.cleanup_job_id)
except Exception:
# Best-effort: the orphaned job no-ops against a revoked lease.
logger.warning(
"Failed to cancel cleanup job %s for lease %s",
lease.cleanup_job_id,
lease.id,
)


class DeleteAccountMutation(graphene.Mutation):
"""Permanently delete the session user's account. Type-to-confirm is
Expand Down
4 changes: 4 additions & 0 deletions backend/backend/graphene/mutations/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,10 @@ def mutate(cls, root, info, environment_id):
):
raise GraphQLError("You do not have permission to delete environments")

# Environments:delete is app-wide (incl. team overrides); env keys set scope.
if not user_can_access_environment(user.userId, environment.id):
raise GraphQLError("You don't have access to this environment")

# An env that contains live rotating secrets can't be deleted without
# RotatingSecrets:delete β€” otherwise a caller with only Environments:delete
# could destroy a rotation config (and its provider creds via cascade).
Expand Down
13 changes: 8 additions & 5 deletions backend/ee/integrations/secrets/dynamic/aws/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,7 @@ def create_access_key(username, iam_client):
raise


def get_sts_client(region="us-east-1"):
def get_sts_client(region="us-east-1", config=None):

aws_access_key_id = get_secret("AWS_INTEGRATION_ACCESS_KEY_ID")
aws_secret_access_key = get_secret("AWS_INTEGRATION_SECRET_ACCESS_KEY")
Expand All @@ -368,22 +368,23 @@ def get_sts_client(region="us-east-1"):
region_name=region,
aws_access_key_id=aws_access_key_id,
aws_secret_access_key=aws_secret_access_key,
config=config,
)
else:
sts_client = boto3.client("sts", region_name=region)
sts_client = boto3.client("sts", region_name=region, config=config)

return sts_client


import boto3


def get_iam_client(secret: DynamicSecret) -> tuple[boto3.client, dict]:
def get_iam_client(secret: DynamicSecret, config=None) -> tuple[boto3.client, dict]:
"""
Construct an IAM client using the given DynamicSecret's authentication config.
Returns (iam_client, aws_credentials).
"""
sts_client = get_sts_client()
sts_client = get_sts_client(config=config)

# Determine authentication method
has_role_arn = "role_arn" in secret.authentication.credentials
Expand Down Expand Up @@ -418,6 +419,7 @@ def get_iam_client(secret: DynamicSecret) -> tuple[boto3.client, dict]:
"region_name": region,
"aws_access_key_id": aws_credentials["AccessKeyId"],
"aws_secret_access_key": aws_credentials["SecretAccessKey"],
"config": config,
}
if "SessionToken" in aws_credentials:
iam_client_kwargs["aws_session_token"] = aws_credentials["SessionToken"]
Expand Down Expand Up @@ -619,6 +621,7 @@ def revoke_aws_dynamic_secret_lease(
request=None,
organisation_member=None,
service_account=None,
client_config=None,
):
"""
Delete IAM user and all associated credentials.
Expand All @@ -640,7 +643,7 @@ def revoke_aws_dynamic_secret_lease(

logger.info(f"Revoking lease {lease.id} (manual={manual})")

iam_client, _ = get_iam_client(lease.secret)
iam_client, _ = get_iam_client(lease.secret, config=client_config)

meta = {
"action": "revoke",
Expand Down
Loading
Loading