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

Add cloud account configuration step #125

Merged
merged 17 commits into from
Oct 30, 2023
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
9 changes: 8 additions & 1 deletion fixbackend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
from fixbackend.domain_events import DomainEventsStreamName
from fixbackend.domain_events.consumers import CustomerIoEventConsumer
from fixbackend.domain_events.publisher_impl import DomainEventPublisherImpl
from fixbackend.errors import AccessDenied
from fixbackend.errors import AccessDenied, ResourceNotFound
from fixbackend.events.router import websocket_router
from fixbackend.graph_db.service import GraphDatabaseAccessManager
from fixbackend.inventory.inventory_client import InventoryClient
Expand All @@ -70,6 +70,7 @@
from fixbackend.workspaces.router import workspaces_router
from fixbackend.cloud_accounts.last_scan_repository import LastScanRepository
from fixbackend.middleware.x_real_ip import RealIpMiddleware
from fixbackend.cloud_accounts.account_setup import AwsAccountSetupHelper

log = logging.getLogger(__name__)
API_PREFIX = "/api"
Expand Down Expand Up @@ -165,6 +166,8 @@ async def setup_teardown_application(_: FastAPI) -> AsyncIterator[None]:
domain_event_publisher,
LastScanRepository(session_maker),
arq_redis,
cfg,
AwsAccountSetupHelper(boto_session),
),
)

Expand Down Expand Up @@ -315,6 +318,10 @@ async def setup_teardown_billing(_: FastAPI) -> AsyncIterator[None]:
async def access_denied_handler(request: Request, exception: AccessDenied) -> Response:
return JSONResponse(status_code=403, content={"message": str(exception)})

@app.exception_handler(ResourceNotFound)
async def resource_not_found_handler(request: Request, exception: ResourceNotFound) -> Response:
return JSONResponse(status_code=404, content={"message": str(exception)})

class EndpointFilter(logging.Filter):
endpoints_to_filter: ClassVar[Set[str]] = {
"/health",
Expand Down
2 changes: 1 addition & 1 deletion fixbackend/auth/auth_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ async def read_token(self, token: Optional[str], user_manager: BaseUserManager[U
available_keys = {self.kid(key): key for key in self.public_keys}

if not (key_id in available_keys):
raise ValueError("Token signed with unknown key")
raise ValueError("Token signed with an unknown key")

public_key = available_keys[key_id]

Expand Down
33 changes: 33 additions & 0 deletions fixbackend/cloud_accounts/account_setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Copyright (c) 2023. Some Engineering
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

import boto3

from fixcloudutils.asyncio.async_extensions import run_async


class AwsAccountSetupHelper:
def __init__(self, session: boto3.Session) -> None:
self.sts_client = session.client("sts")

async def can_assume_role(self, account_id: str, role_name: str) -> bool:
try:
await run_async(
self.sts_client.assume_role,
RoleArn=f"arn:aws:iam::{account_id}:role/{role_name}",
RoleSessionName="FixBackend",
)
return True
except Exception:
return False
2 changes: 2 additions & 0 deletions fixbackend/cloud_accounts/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ class CloudAccount:
workspace_id: WorkspaceId
name: Optional[str]
access: CloudAccess
is_configured: bool
enabled: bool


@frozen
Expand Down
13 changes: 11 additions & 2 deletions fixbackend/cloud_accounts/models/orm.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from typing import Optional

from fastapi_users_db_sqlalchemy.generics import GUID
from sqlalchemy import ForeignKey, String, UniqueConstraint
from sqlalchemy import ForeignKey, String, UniqueConstraint, Boolean
from sqlalchemy.orm import Mapped, mapped_column

from fixbackend.base_model import Base
Expand All @@ -35,6 +35,8 @@ class CloudAccount(Base):
aws_external_id: Mapped[ExternalId] = mapped_column(GUID, nullable=False)
aws_role_name: Mapped[str] = mapped_column(String(length=64), nullable=False)
name: Mapped[Optional[str]] = mapped_column(String(length=64), nullable=True)
is_configured: Mapped[bool] = mapped_column(Boolean, nullable=False)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False)
__table_args__ = (UniqueConstraint("tenant_id", "account_id"),)

def to_model(self) -> models.CloudAccount:
Expand All @@ -47,4 +49,11 @@ def access() -> models.CloudAccess:
case _:
raise ValueError(f"Unknown cloud {self.cloud}")

return models.CloudAccount(id=self.id, workspace_id=self.tenant_id, access=access(), name=self.name)
return models.CloudAccount(
id=self.id,
workspace_id=self.tenant_id,
access=access(),
name=self.name,
is_configured=self.is_configured,
enabled=self.enabled,
)
31 changes: 23 additions & 8 deletions fixbackend/cloud_accounts/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,17 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from typing import Annotated, List, Optional
from abc import ABC, abstractmethod
from typing import Annotated, Callable, List, Optional

from fastapi import Depends
from sqlalchemy import select

from fixbackend.cloud_accounts.models import orm, CloudAccount, AwsCloudAccess
from fixbackend.cloud_accounts.models import AwsCloudAccess, CloudAccount, orm
from fixbackend.db import AsyncSessionMakerDependency
from fixbackend.errors import ResourceNotFound
from fixbackend.ids import FixCloudAccountId, WorkspaceId
from fixbackend.types import AsyncSessionMaker
from abc import ABC, abstractmethod


class CloudAccountRepository(ABC):
Expand All @@ -33,11 +34,13 @@ async def get(self, id: FixCloudAccountId) -> Optional[CloudAccount]:
raise NotImplementedError

@abstractmethod
async def update(self, id: FixCloudAccountId, cloud_account: CloudAccount) -> CloudAccount:
async def update(self, id: FixCloudAccountId, update_fn: Callable[[CloudAccount], CloudAccount]) -> CloudAccount:
raise NotImplementedError

@abstractmethod
async def list_by_workspace_id(self, workspace_id: WorkspaceId) -> List[CloudAccount]:
async def list_by_workspace_id(
self, workspace_id: WorkspaceId, enabled: Optional[bool] = None, configured: Optional[bool] = None
) -> List[CloudAccount]:
raise NotImplementedError

@abstractmethod
Expand All @@ -61,6 +64,8 @@ async def create(self, cloud_account: CloudAccount) -> CloudAccount:
aws_role_name=cloud_account.access.role_name,
aws_external_id=cloud_account.access.external_id,
name=cloud_account.name,
is_configured=cloud_account.is_configured,
enabled=cloud_account.enabled,
)
else:
raise ValueError(f"Unknown cloud {cloud_account.access}")
Expand All @@ -75,13 +80,17 @@ async def get(self, id: FixCloudAccountId) -> Optional[CloudAccount]:
cloud_account = await session.get(orm.CloudAccount, id)
return cloud_account.to_model() if cloud_account else None

async def update(self, id: FixCloudAccountId, cloud_account: CloudAccount) -> CloudAccount:
async def update(self, id: FixCloudAccountId, update_fn: Callable[[CloudAccount], CloudAccount]) -> CloudAccount:
async with self.session_maker() as session:
stored_account = await session.get(orm.CloudAccount, id)
if stored_account is None:
raise ValueError(f"Cloud account {id} not found")
raise ResourceNotFound(f"Cloud account {id} not found")

cloud_account = update_fn(stored_account.to_model())

stored_account.name = cloud_account.name
stored_account.is_configured = cloud_account.is_configured
stored_account.enabled = cloud_account.enabled

match cloud_account.access:
case AwsCloudAccess(account_id, external_id, role_name):
Expand All @@ -98,10 +107,16 @@ async def update(self, id: FixCloudAccountId, cloud_account: CloudAccount) -> Cl
await session.refresh(stored_account)
return stored_account.to_model()

async def list_by_workspace_id(self, workspace_id: WorkspaceId) -> List[CloudAccount]:
async def list_by_workspace_id(
self, workspace_id: WorkspaceId, enabled: Optional[bool] = None, configured: Optional[bool] = None
) -> List[CloudAccount]:
"""Get a list of cloud accounts by tenant id."""
async with self.session_maker() as session:
statement = select(orm.CloudAccount).where(orm.CloudAccount.tenant_id == workspace_id)
if enabled is not None:
statement = statement.where(orm.CloudAccount.enabled == enabled)
if configured is not None:
statement = statement.where(orm.CloudAccount.is_configured == configured)
results = await session.execute(statement)
accounts = results.scalars().all()
return [acc.to_model() for acc in accounts]
Expand Down
33 changes: 27 additions & 6 deletions fixbackend/cloud_accounts/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

import logging

from fastapi import APIRouter, HTTPException
from fastapi import APIRouter

from typing import List

Expand Down Expand Up @@ -42,17 +42,20 @@ async def get_cloud_account(
service: CloudAccountServiceDependency,
) -> CloudAccountRead:
cloud_account = await service.get_cloud_account(cloud_account_id, workspace.id)
if cloud_account is None:
raise HTTPException(status_code=404, detail="Cloud account not found")

return CloudAccountRead.from_model(cloud_account)

@router.get("/{workspace_id}/cloud_accounts")
async def list_cloud_accounts(
workspace: UserWorkspaceDependency, service: CloudAccountServiceDependency
) -> List[CloudAccountRead]:
cloud_accounts = await service.list_accounts(workspace.id)
return [CloudAccountRead.from_model(cloud_account) for cloud_account in cloud_accounts]
last_scan = await service.last_scan(workspace.id)
accounts = last_scan.accounts if last_scan else {}
next_scan = last_scan.next_scan if last_scan else None
return [
CloudAccountRead.from_model(cloud_account, accounts.get(cloud_account.id), next_scan)
for cloud_account in cloud_accounts
]

@router.patch("/{workspace_id}/cloud_account/{cloud_account_id}")
async def update_cloud_account(
Expand All @@ -61,7 +64,7 @@ async def update_cloud_account(
service: CloudAccountServiceDependency,
update: AwsCloudAccountUpdate,
) -> CloudAccountRead:
updated = await service.update_cloud_account(workspace.id, cloud_account_id, update.name)
updated = await service.update_cloud_account_name(workspace.id, cloud_account_id, update.name)
return CloudAccountRead.from_model(updated)

@router.delete("/{workspace_id}/cloud_account/{cloud_account_id}")
Expand All @@ -72,6 +75,24 @@ async def delete_cloud_account(
) -> None:
await service.delete_cloud_account(cloud_account_id, workspace.id)

@router.patch("/{workspace_id}/cloud_account/{cloud_account_id}/enable")
async def enable_cloud_account(
workspace: UserWorkspaceDependency,
cloud_account_id: FixCloudAccountId,
service: CloudAccountServiceDependency,
) -> CloudAccountRead:
updated = await service.enable_cloud_account(workspace.id, cloud_account_id)
return CloudAccountRead.from_model(updated)

@router.patch("/{workspace_id}/cloud_account/{cloud_account_id}/disable")
async def disable_cloud_account(
workspace: UserWorkspaceDependency,
cloud_account_id: FixCloudAccountId,
service: CloudAccountServiceDependency,
) -> CloudAccountRead:
updated = await service.disable_cloud_account(workspace.id, cloud_account_id)
return CloudAccountRead.from_model(updated)

@router.get("/{workspace_id}/cloud_accounts/last_scan")
async def last_scan(
workspace: UserWorkspaceDependency,
Expand Down
16 changes: 13 additions & 3 deletions fixbackend/cloud_accounts/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from typing import List, Optional
from datetime import datetime
from fixbackend.ids import WorkspaceId, ExternalId, CloudAccountId, FixCloudAccountId
from fixbackend.cloud_accounts.models import CloudAccount
from fixbackend.cloud_accounts.models import CloudAccount, LastScanAccountInfo


class AwsCloudFormationLambdaCallbackParameters(BaseModel):
Expand All @@ -40,18 +40,28 @@ class AwsCloudFormationLambdaCallbackParameters(BaseModel):


class CloudAccountRead(BaseModel):
id: FixCloudAccountId = Field(description="Fix cloud account ID")
id: FixCloudAccountId = Field(description="Fix internal cloud account ID, users should not typically see this")
cloud: str = Field(description="Cloud provider")
account_id: CloudAccountId = Field(description="Cloud account ID, as defined by the cloud provider")
name: Optional[str] = Field(description="Name of the cloud account", max_length=64)
enabled: bool = Field(description="Whether the cloud account is enabled for collection")
is_configured: bool = Field(description="Is account correctly configured")
resources: Optional[int] = Field(description="Number of resources in the account")
next_scan: Optional[datetime] = Field(description="Next scheduled scan")

@staticmethod
def from_model(model: CloudAccount) -> "CloudAccountRead":
def from_model(
model: CloudAccount, last_scan_info: Optional[LastScanAccountInfo] = None, next_scan: Optional[datetime] = None
) -> "CloudAccountRead":
return CloudAccountRead(
id=model.id,
cloud=model.access.cloud,
account_id=model.access.account_id(),
name=model.name,
enabled=model.enabled,
is_configured=model.is_configured,
resources=last_scan_info.resources_scanned if last_scan_info else None,
next_scan=next_scan,
)


Expand Down
24 changes: 20 additions & 4 deletions fixbackend/cloud_accounts/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,7 @@ async def last_scan(self, workspace_id: WorkspaceId) -> Optional[LastScanInfo]:
raise NotImplementedError

@abstractmethod
async def get_cloud_account(
self, cloud_account_id: FixCloudAccountId, workspace_id: WorkspaceId
) -> Optional[CloudAccount]:
async def get_cloud_account(self, cloud_account_id: FixCloudAccountId, workspace_id: WorkspaceId) -> CloudAccount:
"""Get a cloud account."""
raise NotImplementedError

Expand All @@ -56,11 +54,29 @@ async def list_accounts(self, workspace_id: WorkspaceId) -> List[CloudAccount]:
raise NotImplementedError

@abstractmethod
async def update_cloud_account(
async def update_cloud_account_name(
self,
workspace_id: WorkspaceId,
cloud_account_id: FixCloudAccountId,
name: str,
) -> CloudAccount:
"""Update a cloud account."""
raise NotImplementedError

@abstractmethod
async def enable_cloud_account(
self,
workspace_id: WorkspaceId,
cloud_account_id: FixCloudAccountId,
) -> CloudAccount:
"""Enable a cloud account."""
raise NotImplementedError

@abstractmethod
async def disable_cloud_account(
self,
workspace_id: WorkspaceId,
cloud_account_id: FixCloudAccountId,
) -> CloudAccount:
"""Disable a cloud account."""
raise NotImplementedError
Loading