Skip to content
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
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ A web application that provides visual representation of AWS infrastructure and

## Quick Start

> **Deploying to AWS?** This section covers local development with Docker. For production deployment to AWS ECS Fargate using Terraform, see the [Deployment](#deployment) section or the full [Infrastructure Guide](infrastructure/README.md).

### Prerequisites

- Python 3.11+
Expand Down Expand Up @@ -495,7 +497,9 @@ terraform apply

See [infrastructure/README.md](infrastructure/README.md) for detailed deployment instructions.

## IAM Permissions
## Required Permissions

### AWS IAM Permissions

The application task role is managed by the standalone `iam` module (`infrastructure/modules/iam/`), decoupled from the ECS module so that it persists independently of the deployment mechanism. The required permissions:

Expand Down Expand Up @@ -558,6 +562,32 @@ The application task role is managed by the standalone `iam` module (`infrastruc
}
```

### CyberArk Service Account Permissions

When CyberArk integration is enabled (`CYBERARK_ENABLED=true`), the application requires two dedicated service users in CyberArk with the following minimum permissions.

#### SCIM Service User (Identity User/Role Sync)

Used to collect users and roles from CyberArk Identity via the SCIM API. Configured via the SCIM settings in the admin UI (`/api/settings/cyberark/scim`).

| Resource | Permission | Purpose |
|----------|------------|---------|
| Users | Read | Enumerate Identity users for access mapping |
| Groups / Roles | Read | Enumerate Identity roles and role memberships |

#### Platform API Service User (Privilege Cloud + SIA)

Used to collect safes, privileged accounts, safe memberships, and Secure Infrastructure Access (SIA) policies from Privilege Cloud. Configured via the CyberArk settings in the admin UI (`/api/settings/cyberark`).

| Resource | Permission | Purpose |
|----------|------------|---------|
| Safes | List / Read (or **Privilege Cloud Auditors** role) | Enumerate safes and safe members |
| Accounts | List Accounts (or **Privilege Cloud Auditors** role) | Enumerate privileged accounts within safes |
| Safe Members | Read (or **Privilege Cloud Auditors** role) | Enumerate safe membership for access mapping |
| UAP Policies | Read | Read Secure Infrastructure Access (SIA) policies for JIT access mapping |

> **Tip**: Assigning the built-in **Privilege Cloud Auditors** role to the Platform API service user grants read-only access to safes, accounts, and safe members in a single step. If a more restrictive approach is preferred, grant individual permissions to only the desired safes.

## Tech Stack

### Backend
Expand Down
1 change: 1 addition & 0 deletions backend/app/api/routes/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,7 @@ async def create_terraform_bucket(
region=data.region,
description=data.description,
prefix=data.prefix,
excluded_paths=data.excluded_paths,
enabled=data.enabled,
)
db.add(bucket)
Expand Down
3 changes: 3 additions & 0 deletions backend/app/models/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ class TerraformStateBucket(Base):
region: Mapped[Optional[str]] = mapped_column(String(30), nullable=True)
description: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
prefix: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
excluded_paths: Mapped[Optional[str]] = mapped_column(
Text, nullable=True
) # Comma-separated glob patterns to exclude during auto-discovery
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
source: Mapped[str] = mapped_column(
String(20), nullable=False, default="manual"
Expand Down
27 changes: 21 additions & 6 deletions backend/app/parsers/terraform.py
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,7 @@ async def _load_db_buckets(self) -> List[Dict[str, Any]]:
"prefix": row.prefix,
"description": row.description,
"source": row.source,
"excluded_paths": row.excluded_paths,
"explicit_paths": explicit_paths,
}
)
Expand Down Expand Up @@ -498,6 +499,7 @@ async def _get_all_bucket_configs(
region = db_bucket.get("region")
source = db_bucket.get("source", "manual")
explicit_paths = db_bucket.get("explicit_paths", [])
excluded_paths = db_bucket.get("excluded_paths") or ""

state_configs: List[Dict[str, Any]] = []

Expand All @@ -513,7 +515,7 @@ async def _get_all_bucket_configs(
elif not state_configs:
# No explicit paths and no YAML -> auto-discover
discovered = await self._discover_state_files(
bucket_name, prefix, region
bucket_name, prefix, region, excluded_paths
)
state_configs.extend(discovered)

Expand Down Expand Up @@ -542,7 +544,11 @@ async def _get_all_bucket_configs(
return entries

async def _discover_state_files(
self, bucket_name: str, prefix: str, region: Optional[str] = None
self,
bucket_name: str,
prefix: str,
region: Optional[str] = None,
excluded_paths: str = "",
) -> List[Dict[str, Any]]:
"""
Discover .tfstate files in an S3 bucket under the given prefix.
Expand All @@ -551,10 +557,21 @@ async def _discover_state_files(
bucket_name: S3 bucket name
prefix: Key prefix to search under
region: AWS region for the S3 client
excluded_paths: Comma-separated glob patterns to exclude

Returns:
List of state file configurations
"""
import fnmatch

# Build exclusion patterns from both hardcoded defaults and user config
exclude_patterns = ["*/archive/*", "*/backup/*"]
if excluded_paths:
for pattern in excluded_paths.split(","):
pattern = pattern.strip()
if pattern:
exclude_patterns.append(pattern)

try:
session_kwargs: Dict[str, Any] = {}
if settings.aws_profile:
Expand All @@ -568,10 +585,8 @@ async def _discover_state_files(
for page in paginator.paginate(Bucket=bucket_name, Prefix=prefix):
for obj in page.get("Contents", []):
key = obj["Key"]
if (
key.endswith(".tfstate")
and "/archive/" not in key
and "/backup/" not in key
if key.endswith(".tfstate") and not any(
fnmatch.fnmatch(key, pat) for pat in exclude_patterns
):
# Derive a human-readable name from the key
name = key
Expand Down
12 changes: 12 additions & 0 deletions backend/app/schemas/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,13 @@ class TerraformBucketCreate(BaseModel):
description="S3 key prefix to filter state files (e.g., 'lab/')",
max_length=500,
)
excluded_paths: Optional[str] = Field(
None,
description=(
"Comma-separated glob patterns to exclude during auto-discovery "
"(e.g., '*/archive/*,*/backup/*,*/test/*')"
),
)
enabled: bool = Field(True, description="Whether this bucket is active")


Expand All @@ -132,6 +139,10 @@ class TerraformBucketUpdate(BaseModel):
region: Optional[str] = Field(None, description="AWS region", max_length=30)
description: Optional[str] = Field(None, description="Description", max_length=500)
prefix: Optional[str] = Field(None, description="S3 key prefix", max_length=500)
excluded_paths: Optional[str] = Field(
None,
description="Comma-separated glob patterns to exclude during auto-discovery",
)
enabled: Optional[bool] = Field(None, description="Whether this bucket is active")


Expand Down Expand Up @@ -186,6 +197,7 @@ class TerraformBucketResponse(BaseModel):
region: Optional[str] = None
description: Optional[str] = None
prefix: Optional[str] = None
excluded_paths: Optional[str] = None
enabled: bool = True
source: str = "manual"
paths: List[TerraformPathResponse] = []
Expand Down
44 changes: 44 additions & 0 deletions frontend/src/components/settings/TerraformBucketsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,12 @@ function BucketCard({
? `${pathCount} path${pathCount !== 1 ? "s" : ""} configured`
: "Auto-discovery"}
</span>
{bucket.excluded_paths && (
<span>
Exclusions:{" "}
<code className="text-xs">{bucket.excluded_paths}</code>
</span>
)}
</div>
</div>
<div className="ml-4 flex items-center gap-1 flex-shrink-0">
Expand Down Expand Up @@ -896,6 +902,7 @@ function BucketAddForm({ onSave, onCancel }: BucketAddFormProps) {
const [region, setRegion] = useState("");
const [description, setDescription] = useState("");
const [prefix, setPrefix] = useState("");
const [excludedPaths, setExcludedPaths] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);

Expand All @@ -909,6 +916,7 @@ function BucketAddForm({ onSave, onCancel }: BucketAddFormProps) {
region: region.trim() || undefined,
description: description.trim() || undefined,
prefix: prefix.trim() || undefined,
excluded_paths: excludedPaths.trim() || undefined,
enabled: true,
});
} catch (err: unknown) {
Expand Down Expand Up @@ -975,6 +983,22 @@ function BucketAddForm({ onSave, onCancel }: BucketAddFormProps) {
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Excluded Paths
</label>
<input
type="text"
value={excludedPaths}
onChange={(e) => setExcludedPaths(e.target.value)}
placeholder="*/archive/*, */backup/*, */test/* (comma-separated)"
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
Comma-separated glob patterns to exclude during auto-discovery.
Paths matching */archive/* and */backup/* are always excluded.
</p>
</div>
{saveError && (
<p className="text-sm text-red-600 dark:text-red-400">{saveError}</p>
)}
Expand Down Expand Up @@ -1009,6 +1033,9 @@ function BucketEditForm({ bucket, onSave, onCancel }: BucketEditFormProps) {
const [region, setRegion] = useState(bucket.region || "");
const [description, setDescription] = useState(bucket.description || "");
const [prefix, setPrefix] = useState(bucket.prefix || "");
const [excludedPaths, setExcludedPaths] = useState(
bucket.excluded_paths || "",
);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);

Expand All @@ -1023,6 +1050,7 @@ function BucketEditForm({ bucket, onSave, onCancel }: BucketEditFormProps) {
region: region.trim() || undefined,
description: description.trim() || undefined,
prefix: prefix.trim() || undefined,
excluded_paths: excludedPaths.trim() || undefined,
};
// Only allow renaming non-env buckets
if (!isEnvBucket) {
Expand Down Expand Up @@ -1099,6 +1127,22 @@ function BucketEditForm({ bucket, onSave, onCancel }: BucketEditFormProps) {
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Excluded Paths
</label>
<input
type="text"
value={excludedPaths}
onChange={(e) => setExcludedPaths(e.target.value)}
placeholder="*/archive/*, */backup/*, */test/* (comma-separated)"
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
Comma-separated glob patterns to exclude during auto-discovery.
Paths matching */archive/* and */backup/* are always excluded.
</p>
</div>
{saveError && (
<p className="text-sm text-red-600 dark:text-red-400">{saveError}</p>
)}
Expand Down
48 changes: 42 additions & 6 deletions frontend/src/components/topology/TopologyFilterBar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from "react";
import { useState, useRef, useEffect, useCallback } from "react";
import { Search, X, Filter, ChevronDown } from "lucide-react";
import type { TopologyFilters, TopologyVPC } from "@/types/topology";
import { hasActiveFilters } from "./utils/topologyFilter";
Expand Down Expand Up @@ -101,6 +101,37 @@ export function TopologyFilterBar({
onChange,
vpcs,
}: TopologyFilterBarProps) {
// Local state for search input to enable debouncing
const [searchValue, setSearchValue] = useState(filters.search);

// Use refs to access latest values in debounce effect without triggering re-runs
const filtersRef = useRef(filters);
const onChangeRef = useRef(onChange);

useEffect(() => {
filtersRef.current = filters;
onChangeRef.current = onChange;
}, [filters, onChange]);

// Debounce search input - only update filters after user stops typing
useEffect(() => {
const timer = setTimeout(() => {
if (searchValue !== filtersRef.current.search) {
onChangeRef.current({ ...filtersRef.current, search: searchValue });
}
}, 400);
return () => clearTimeout(timer);
}, [searchValue]);

// Sync external filter changes back to local state
const syncSearch = useCallback((externalSearch: string) => {
setSearchValue(externalSearch);
}, []);

useEffect(() => {
syncSearch(filters.search);
}, [filters.search, syncSearch]);

const activeCount =
[
filters.search,
Expand All @@ -113,7 +144,8 @@ export function TopologyFilterBar({
const update = (partial: Partial<TopologyFilters>) =>
onChange({ ...filters, ...partial });

const clearAll = () =>
const clearAll = () => {
setSearchValue("");
onChange({
search: "",
vpcId: "",
Expand All @@ -122,6 +154,7 @@ export function TopologyFilterBar({
tfManaged: "",
resourceTypes: [],
});
};

return (
<div className="absolute top-4 left-4 z-10 flex items-center gap-2 flex-wrap">
Expand All @@ -131,13 +164,16 @@ export function TopologyFilterBar({
<input
type="text"
placeholder="Search resources..."
value={filters.search}
onChange={(e) => update({ search: e.target.value })}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
className="h-8 w-48 rounded-md border border-gray-300 bg-white pl-8 pr-7 text-xs placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder:text-gray-500"
/>
{filters.search && (
{searchValue && (
<button
onClick={() => update({ search: "" })}
onClick={() => {
setSearchValue("");
update({ search: "" });
}}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X className="h-3.5 w-3.5" />
Expand Down
21 changes: 21 additions & 0 deletions frontend/src/components/topology/utils/topologyFilter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,19 @@ function filterSubnets(
);
}

// If tfManaged filter is active, only keep subnet if it matches OR has matching children
if (
filters.tfManaged &&
!matchesTfManaged(subnet.tf_managed, filters.tfManaged)
) {
return (
subnet.ec2_instances.length > 0 ||
subnet.rds_instances.length > 0 ||
subnet.ecs_containers.length > 0 ||
subnet.nat_gateway !== null
);
}

// If search is active, only keep subnet if it has matching children
if (filters.search) {
return (
Expand Down Expand Up @@ -328,6 +341,14 @@ export function filterTopologyData(
)
return true;

// If tfManaged filter is active, only keep VPC if it matches OR has surviving children
if (
filters.tfManaged &&
!matchesTfManaged(vpc.tf_managed, filters.tfManaged)
) {
return vpc.subnets.length > 0 || vpc.internet_gateway !== null;
}

// Keep VPC if it has any surviving children
return vpc.subnets.length > 0 || vpc.internet_gateway !== null;
});
Expand Down
Loading
Loading