Skip to content

Commit

Permalink
feat: 网关权限快过期时,发送邮件通知 (#388)
Browse files Browse the repository at this point in the history
  • Loading branch information
alex-smile authored Dec 8, 2023
1 parent 406e8a2 commit cdc7d37
Show file tree
Hide file tree
Showing 18 changed files with 395 additions and 36 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -72,18 +72,27 @@ def doc_link(self):

@property
def permission_status(self) -> str:
if not self.component_perm_required or self.expires_in == math.inf:
# 如果组件不需要权限校验,则权限类型为:无限制,即默认拥有权限
if not self.component_perm_required:
return PermissionStatusEnum.UNLIMITED.value

# 如果权限记录中,有效期为永久有效;权限不需要再申请,优先展示
if self.expires_in == math.inf:
return PermissionStatusEnum.OWNED.value

# 如果权限已有申请状态,如已拒绝、申请中;优先展示
if self.component_permission_apply_status:
return self.component_permission_apply_status

# 有权限且未过期
if self.expires_in > 0:
return PermissionStatusEnum.OWNED.value

# 有权限,但是已过期
if self.expires_in > -math.inf:
return PermissionStatusEnum.EXPIRED.value

# 无权限,待申请
return PermissionStatusEnum.NEED_APPLY.value

@cached_property
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,12 @@ def _need_to_apply_permission(self, permission_status):
return permission_status not in [
PermissionStatusEnum.PENDING.value,
PermissionStatusEnum.OWNED.value,
PermissionStatusEnum.UNLIMITED.value,
]

def _need_to_renew_permission(self, permission_status, expires_in):
if permission_status in [PermissionStatusEnum.OWNED.value] and 0 < expires_in < time.to_seconds(
days=RENEWABLE_EXPIRE_DAYS
):
renewable_end_time = time.to_seconds(days=RENEWABLE_EXPIRE_DAYS)
if permission_status in [PermissionStatusEnum.OWNED.value] and 0 < expires_in < renewable_end_time:
return True

return False
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,18 +79,27 @@ def permission_level(self):

@property
def permission_status(self):
if not self.resource_perm_required or self.expires_in == math.inf:
# 如果资源不需要权限校验,则权限类型为:无限制,即默认拥有权限
if not self.resource_perm_required:
return PermissionStatusEnum.UNLIMITED.value

# 如果权限记录中,有效期为永久有效;权限不需要再申请,优先展示
if self.expires_in == math.inf:
return PermissionStatusEnum.OWNED.value

# 如果权限已有申请状态,如已拒绝、申请中;优先展示
if self.api_permission_apply_status or self.resource_permission_apply_status:
return self.api_permission_apply_status or self.resource_permission_apply_status

# 有权限且未过期
if self.expires_in > 0:
return PermissionStatusEnum.OWNED.value

# 有权限,但是已过期
if self.expires_in > -math.inf:
return PermissionStatusEnum.EXPIRED.value

# 无权限,待申请
return PermissionStatusEnum.NEED_APPLY.value

@cached_property
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,12 @@ def _need_to_apply_permission(self, permission_status):
return permission_status not in [
PermissionStatusEnum.PENDING.value,
PermissionStatusEnum.OWNED.value,
PermissionStatusEnum.UNLIMITED.value,
]

def _need_to_renew_permission(self, permission_status, expires_in):
if permission_status in [PermissionStatusEnum.OWNED.value] and 0 < expires_in < time.to_seconds(
days=RENEWABLE_EXPIRE_DAYS
):
renewable_end_time = time.to_seconds(days=RENEWABLE_EXPIRE_DAYS)
if permission_status in [PermissionStatusEnum.OWNED.value] and 0 < expires_in < renewable_end_time:
return True

return False
Expand Down
32 changes: 17 additions & 15 deletions src/dashboard/apigateway/apigateway/apis/open/permission/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,23 @@
from apigateway.apis.open.permission import views

urlpatterns = [
# For gateway manage permissions
path(
"apis/<slug:gateway_name>/permissions/apply/",
views.AppPermissionApplyV1APIView.as_view(),
name="openapi.permission.apply.api",
),
path(
"apis/<slug:gateway_name>/permissions/grant/",
views.AppPermissionGrantViewSet.as_view({"post": "grant"}),
name="openapi.permission.grant",
),
path(
"apis/<slug:gateway_name>/permissions/revoke/",
views.RevokeAppPermissionViewSet.as_view({"delete": "revoke"}),
name="openapi.permission.revoke",
),
# For app api permission management in paas developer center
path(
"apis/<int:gateway_id>/permissions/resources/",
views.ResourceViewSet.as_view({"get": "list"}),
Expand All @@ -42,21 +59,6 @@
views.AppPermissionRenewAPIView.as_view(),
name="openapi.permission.renew.deprecated",
),
path(
"apis/<slug:gateway_name>/permissions/apply/",
views.AppPermissionApplyV1APIView.as_view(),
name="openapi.permission.apply.api",
),
path(
"apis/<slug:gateway_name>/permissions/grant/",
views.AppPermissionGrantViewSet.as_view({"post": "grant"}),
name="openapi.permission.grant",
),
path(
"apis/<slug:gateway_name>/permissions/revoke/",
views.RevokeAppPermissionViewSet.as_view({"delete": "revoke"}),
name="openapi.permission.revoke",
),
path(
"apis/permissions/renew/",
views.AppPermissionRenewAPIView.as_view(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,12 @@ class ApplyStatusEnum(StructuredEnum):
# Restricted Enum subclassing
# https://docs.python.org/3/library/enum.html#restricted-subclassing-of-enumerations
class PermissionStatusEnum(ChoiceEnum):
UNLIMITED = "unlimited" # 无限制
APPROVED = "approved"
REJECTED = "rejected"
PENDING = "pending"
NEED_APPLY = "need_apply"
OWNED = "owned"
OWNED = "owned" # 已申请,且未过期
EXPIRED = "expired"


Expand Down Expand Up @@ -84,4 +85,4 @@ class GrantDimensionEnum(StructuredEnum):
# 默认的权限有效期天数
DEFAULT_PERMISSION_EXPIRE_DAYS = 180
# 可续期的过期天数,权限有效期小于此值,允许续期,否则,不允许
RENEWABLE_EXPIRE_DAYS = 30
RENEWABLE_EXPIRE_DAYS = 360
163 changes: 162 additions & 1 deletion src/dashboard/apigateway/apigateway/apps/permission/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import logging
import os
from collections import defaultdict
from typing import Dict, List

from celery import shared_task
from django.conf import settings
Expand All @@ -34,16 +35,26 @@
GrantTypeEnum,
PermissionApplyExpireDaysEnum,
)
from apigateway.apps.permission.models import AppPermissionApply, AppPermissionRecord, AppResourcePermission
from apigateway.apps.permission.models import (
AppAPIPermission,
AppPermissionApply,
AppPermissionRecord,
AppResourcePermission,
)
from apigateway.biz.permission import PermissionDimensionManager
from apigateway.components.cmsi import cmsi_component
from apigateway.components.paasv3 import paasv3_component
from apigateway.core.constants import APIStatusEnum
from apigateway.core.models import Gateway, Resource
from apigateway.utils.file import read_file

logger = logging.getLogger(__name__)


APIGW_LOGO_PATH = os.path.join(settings.BASE_DIR, "static/img/api_gateway.png")

ONE_DAY_SECONDS = 86400


@shared_task(name="apigateway.apps.permission.tasks.send_mail_for_perm_apply", ignore_result=True)
def send_mail_for_perm_apply(record_id):
Expand Down Expand Up @@ -188,3 +199,153 @@ def renew_app_resource_permission():
resource_ids=resource_ids,
grant_type=GrantTypeEnum.AUTO_RENEW.value,
)


class AppPermissionExpiringSoonAlerter:
def __init__(self, expire_in_days: int, expire_day_to_alert: List[int]):
"""
- param expire_in_days: 统计过期时间在指定天内的权限
- param expire_day_to_alert: 需要告警的过期天数,只有过期时间在指定天的权限才会告警,防止告警过多
"""
self.expire_in_days = expire_in_days
self.expire_day_to_alert = expire_day_to_alert

def alert(self):
permissions = self._get_permissions_expiring_soon()

# 过滤掉不告警的记录
filtered_permissions = self._filter_permissions(permissions)

# 不全权限中的网关名、资源名等数据
self._complete_permissions(filtered_permissions)

# 发送告警
self._send_alert(filtered_permissions)

def _get_permissions_expiring_soon(self) -> Dict[str, List]:
now = timezone.now()
expire_end_time = now + datetime.timedelta(days=self.expire_in_days)

permissions = defaultdict(list)

# 按网关的权限
permissions_by_gateway = AppAPIPermission.objects.filter(expires__range=(now, expire_end_time))
for permission in permissions_by_gateway:
permissions[permission.bk_app_code].append(
{
"gateway_id": permission.api_id,
"expire_days": int(permission.expires_in / ONE_DAY_SECONDS),
"grant_dimension": GrantDimensionEnum.API.value,
"grant_dimension_display": GrantDimensionEnum.get_choice_label(GrantDimensionEnum.API.value),
}
)

# 按资源的权限
permissions_by_resource = AppResourcePermission.objects.filter(expires__range=(now, expire_end_time))
for permission in permissions_by_resource:
permissions[permission.bk_app_code].append(
{
"gateway_id": permission.api_id,
"expire_days": int(permission.expires_in / ONE_DAY_SECONDS),
"grant_dimension": GrantDimensionEnum.RESOURCE.value,
"grant_dimension_display": GrantDimensionEnum.get_choice_label(GrantDimensionEnum.RESOURCE.value),
"resource_id": permission.resource_id,
}
)

return permissions

def _filter_permissions(self, permissions: Dict[str, List]) -> Dict[str, List]:
"""
- 过滤掉不在指定告警时间的权限
- 过滤掉已下架网关的权限
"""
gateway_ids = {perm["gateway_id"] for perms in permissions.values() for perm in perms}
inactive_gateway_ids = list(
Gateway.objects.filter(id__in=gateway_ids, status=APIStatusEnum.INACTIVE.value).values_list(
"id", flat=True
)
)

filtered_permissions = defaultdict(list)
for bk_app_code, app_perms in permissions.items():
for app_perm in app_perms:
if app_perm["expire_days"] not in self.expire_day_to_alert:
continue

if app_perm["gateway_id"] in inactive_gateway_ids:
continue

filtered_permissions[bk_app_code].append(app_perm)

return filtered_permissions

def _complete_permissions(self, permissions: Dict[str, List]):
"""补全,完善权限数据"""
gateway_ids = {perm["gateway_id"] for perms in permissions.values() for perm in perms}
resource_ids = {
perm["resource_id"] for perms in permissions.values() for perm in perms if perm.get("resource_id")
}

# 补全网关名称、资源名称
gateway_id_to_fields = {
item["id"]: item for item in Gateway.objects.filter(id__in=gateway_ids).values("id", "name")
}
resource_id_to_fields = {
item["id"]: item for item in Resource.objects.filter(id__in=resource_ids).values("id", "name")
}
for app_perms in permissions.values():
for perm in app_perms:
perm.update(
{
"gateway_name": gateway_id_to_fields[perm["gateway_id"]].get("name", ""),
"resource_name": resource_id_to_fields.get(perm.get("resource_id"), {}).get("name", ""),
}
)

def _send_alert(self, permissions: Dict[str, List]):
for bk_app_code, app_perms in permissions.items():
app_maintainers = paasv3_component.get_app_maintainers(bk_app_code)
if not app_maintainers:
continue

sorted_app_perms = sorted(
app_perms, key=lambda x: (x["gateway_name"], x["grant_dimension"], x["resource_name"])
)

title = f"【蓝鲸API网关】你的应用【{bk_app_code}】访问网关资源的权限即将过期,请尽快处理"

mail_content = render_to_string(
"permission/alert_app_permission_expiring_soon_template.html",
context={
"title": title,
"bk_app_code": bk_app_code,
"permissions": sorted_app_perms,
"renew_permission_link": settings.PAAS_RENEW_API_PERMISSION_URL.format(bk_app_code=bk_app_code),
},
)

params = {
"title": title,
"receiver__username": app_maintainers,
"content": mail_content,
"attachments": [
{
"filename": "api_gateway.png",
"content": base64.b64encode(read_file(APIGW_LOGO_PATH)).decode("utf-8"),
}
],
}

cmsi_component.send_mail(params)


@shared_task(name="apigateway.apps.permission.tasks.alert_app_permission_expiring_soon", ignore_result=True)
def alert_app_permission_expiring_soon():
"""
告警通知应用访问网关权限快过期
- 过期前 15 天开始通知
- 为防止告警消息过多,只通知指定过期天数,如 0, 1, 3, 7, 15
"""
alerter = AppPermissionExpiringSoonAlerter(expire_in_days=16, expire_day_to_alert=[0, 1, 3, 7, 15])
alerter.alert()
16 changes: 15 additions & 1 deletion src/dashboard/apigateway/apigateway/components/paasv3.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
#
import json
import logging
from typing import Any, Optional
from typing import Any, List, Optional

from bkapi.paasv3.shortcuts import get_client_by_username
from bkapi_client_core.utils import to_curl
Expand Down Expand Up @@ -90,5 +90,19 @@ def get_apps(self, app_code_list):

return {app["code"]: app for app in filter(None, result_data)} or {}

def get_app_maintainers(self, bk_app_code: str) -> List[str]:
"""获取应用负责人"""
app = self.get_app(bk_app_code)
if not app:
return []

if app.get("developers"):
return app["developers"]

if app.get("creator"):
return [app["creator"]]

return []


paasv3_component = PaaSV3Component()
4 changes: 4 additions & 0 deletions src/dashboard/apigateway/apigateway/conf/celery_conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@
"task": "apigateway.controller.tasks.syncing.release_updated_check",
"schedule": crontab(minute="*/1"),
},
"apigateway.apps.permission.tasks.alert_app_permission_expiring_soon": {
"task": "apigateway.apps.permission.tasks.alert_app_permission_expiring_soon",
"schedule": crontab(minute=30, hour=14),
},
}

CELERY_CHORD_UNLOCK_MAX_RETRIES = 60
Expand Down
3 changes: 3 additions & 0 deletions src/dashboard/apigateway/apigateway/conf/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,9 @@
# 是否使用 bklog 网关 API
USE_BKAPI_BK_LOG = env.bool("USE_BKAPI_BK_LOG", False)

# paas 开发者中心权限续期地址
BK_PAAS3_URL = env.str("BK_PAAS3_URL", "")
PAAS_RENEW_API_PERMISSION_URL = f"{BK_PAAS3_URL}/developer-center/apps/{{bk_app_code}}/cloudapi"

# ==============================================================================
# Feature Flag
Expand Down
Loading

0 comments on commit cdc7d37

Please sign in to comment.