feat: add security group membership with workload assignment, sg: prefix resolution in policy rules, and searchable select component
Add SecurityGroupMember model with security_group_id/workload_id foreign keys and unique constraint, implement security_group_members table with timestamps, add SecurityGroupMemberCreate/SecurityGroupMemberRead schemas with workload_name/workload_external_id fields, implement workload_provider_targets helper to expand sg: prefix into multiple workload targets with
This commit is contained in:
+169
-27
@@ -27,6 +27,7 @@ from app.models.domain import (
|
|||||||
Project,
|
Project,
|
||||||
Role,
|
Role,
|
||||||
SecurityGroup,
|
SecurityGroup,
|
||||||
|
SecurityGroupMember,
|
||||||
SecurityRule,
|
SecurityRule,
|
||||||
ServiceCatalogItem,
|
ServiceCatalogItem,
|
||||||
SystemSetting,
|
SystemSetting,
|
||||||
@@ -64,6 +65,8 @@ from app.schemas.domain import (
|
|||||||
ServiceCatalogCreate,
|
ServiceCatalogCreate,
|
||||||
ServiceCatalogRead,
|
ServiceCatalogRead,
|
||||||
SecurityGroupCreate,
|
SecurityGroupCreate,
|
||||||
|
SecurityGroupMemberCreate,
|
||||||
|
SecurityGroupMemberRead,
|
||||||
SecurityGroupRead,
|
SecurityGroupRead,
|
||||||
SetupCompleteRequest,
|
SetupCompleteRequest,
|
||||||
SetupStatus,
|
SetupStatus,
|
||||||
@@ -269,6 +272,30 @@ def workload_provider_target(db: Session, cluster: Cluster, ref: str) -> tuple[d
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def workload_provider_targets(db: Session, cluster: Cluster, ref: str) -> list[tuple[dict, Workload]]:
|
||||||
|
resolved = workload_provider_target(db, cluster, ref)
|
||||||
|
if resolved:
|
||||||
|
return [resolved]
|
||||||
|
if not ref.startswith("sg:"):
|
||||||
|
return []
|
||||||
|
group_ref = ref.removeprefix("sg:")
|
||||||
|
group = db.get(SecurityGroup, group_ref) or db.scalar(select(SecurityGroup).where(SecurityGroup.name == group_ref))
|
||||||
|
if not group:
|
||||||
|
return []
|
||||||
|
workloads = db.scalars(
|
||||||
|
select(Workload)
|
||||||
|
.join(SecurityGroupMember, SecurityGroupMember.workload_id == Workload.id)
|
||||||
|
.where(SecurityGroupMember.security_group_id == group.id, Workload.cluster_id == cluster.id)
|
||||||
|
.order_by(Workload.name)
|
||||||
|
).all()
|
||||||
|
targets = []
|
||||||
|
for workload in workloads:
|
||||||
|
target = workload_provider_target(db, cluster, f"workload:{workload.id}")
|
||||||
|
if target:
|
||||||
|
targets.append(target)
|
||||||
|
return targets
|
||||||
|
|
||||||
|
|
||||||
def endpoint_values(db: Session, cluster: Cluster, ref: str) -> tuple[list[str | None], list[str]]:
|
def endpoint_values(db: Session, cluster: Cluster, ref: str) -> tuple[list[str | None], list[str]]:
|
||||||
if ref == "any":
|
if ref == "any":
|
||||||
return [None], []
|
return [None], []
|
||||||
@@ -291,6 +318,29 @@ def endpoint_values(db: Session, cluster: Cluster, ref: str) -> tuple[list[str |
|
|||||||
if values:
|
if values:
|
||||||
return values, []
|
return values, []
|
||||||
return [], [f"Network {network_name} has no IPAM subnets to use as provider-side matcher."]
|
return [], [f"Network {network_name} has no IPAM subnets to use as provider-side matcher."]
|
||||||
|
if ref.startswith("sg:"):
|
||||||
|
group_ref = ref.removeprefix("sg:")
|
||||||
|
group = db.get(SecurityGroup, group_ref) or db.scalar(select(SecurityGroup).where(SecurityGroup.name == group_ref))
|
||||||
|
if not group:
|
||||||
|
return [], [f"Security group {group_ref} was not found."]
|
||||||
|
workload_ids = [
|
||||||
|
row[0]
|
||||||
|
for row in db.execute(
|
||||||
|
select(SecurityGroupMember.workload_id)
|
||||||
|
.join(Workload, Workload.id == SecurityGroupMember.workload_id)
|
||||||
|
.where(SecurityGroupMember.security_group_id == group.id, Workload.cluster_id == cluster.id)
|
||||||
|
).all()
|
||||||
|
]
|
||||||
|
if not workload_ids:
|
||||||
|
return [], [f"Security group {group.name} has no workloads in this cluster."]
|
||||||
|
values = [
|
||||||
|
address.address
|
||||||
|
for address in db.scalars(select(IpAddress).where(IpAddress.workload_id.in_(workload_ids)).order_by(IpAddress.address)).all()
|
||||||
|
if address.address
|
||||||
|
]
|
||||||
|
if values:
|
||||||
|
return values, []
|
||||||
|
return [], [f"Security group {group.name} has no assigned member IPs."]
|
||||||
return [], [f"Endpoint {ref} is not yet resolvable to a Proxmox firewall matcher."]
|
return [], [f"Endpoint {ref} is not yet resolvable to a Proxmox firewall matcher."]
|
||||||
|
|
||||||
|
|
||||||
@@ -491,6 +541,23 @@ def endpoint_ref_matches_flow_side(
|
|||||||
return False
|
return False
|
||||||
subnets = db.scalars(select(Subnet).where(Subnet.network_id == network.id)).all()
|
subnets = db.scalars(select(Subnet).where(Subnet.network_id == network.id)).all()
|
||||||
return any(ip_value_matches(subnet.cidr, flow_ip) for subnet in subnets)
|
return any(ip_value_matches(subnet.cidr, flow_ip) for subnet in subnets)
|
||||||
|
if value.startswith("sg:"):
|
||||||
|
group_ref = value.removeprefix("sg:")
|
||||||
|
group = db.get(SecurityGroup, group_ref) or db.scalar(select(SecurityGroup).where(SecurityGroup.name == group_ref))
|
||||||
|
if not group:
|
||||||
|
return False
|
||||||
|
if side_workload:
|
||||||
|
return db.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(SecurityGroupMember)
|
||||||
|
.where(SecurityGroupMember.security_group_id == group.id, SecurityGroupMember.workload_id == side_workload.id)
|
||||||
|
) > 0
|
||||||
|
member_ips = db.scalars(
|
||||||
|
select(IpAddress.address)
|
||||||
|
.join(SecurityGroupMember, SecurityGroupMember.workload_id == IpAddress.workload_id)
|
||||||
|
.where(SecurityGroupMember.security_group_id == group.id)
|
||||||
|
).all()
|
||||||
|
return flow_ip in set(member_ips)
|
||||||
if is_ip_or_cidr(value):
|
if is_ip_or_cidr(value):
|
||||||
return ip_value_matches(value, flow_ip)
|
return ip_value_matches(value, flow_ip)
|
||||||
return False
|
return False
|
||||||
@@ -858,47 +925,48 @@ def resolve_firewall_preview(db: Session, cluster: Cluster, preview: FirewallPre
|
|||||||
mapped = dict(rule)
|
mapped = dict(rule)
|
||||||
direction = str(rule.get("direction", "ingress"))
|
direction = str(rule.get("direction", "ingress"))
|
||||||
target_ref = str(rule.get("destination") if direction == "ingress" else rule.get("source"))
|
target_ref = str(rule.get("destination") if direction == "ingress" else rule.get("source"))
|
||||||
target = workload_provider_target(db, cluster, target_ref)
|
targets = workload_provider_targets(db, cluster, target_ref)
|
||||||
if not target:
|
if not targets:
|
||||||
conflicts.append(
|
conflicts.append(
|
||||||
f"Rule {rule_index} needs a concrete {'destination' if direction == 'ingress' else 'source'} workload for Proxmox live apply."
|
f"Rule {rule_index} needs a concrete {'destination' if direction == 'ingress' else 'source'} workload for Proxmox live apply."
|
||||||
)
|
)
|
||||||
generated_rules.append(mapped)
|
generated_rules.append(mapped)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
provider_target, target_workload = target
|
|
||||||
remote_ref = str(rule.get("source") if direction == "ingress" else rule.get("destination"))
|
remote_ref = str(rule.get("source") if direction == "ingress" else rule.get("destination"))
|
||||||
remote_values, endpoint_warnings = endpoint_values(db, cluster, remote_ref)
|
remote_values, endpoint_warnings = endpoint_values(db, cluster, remote_ref)
|
||||||
warnings.extend(endpoint_warnings)
|
warnings.extend(endpoint_warnings)
|
||||||
if not remote_values:
|
if not remote_values:
|
||||||
conflicts.append(f"Rule {rule_index} cannot resolve {remote_ref} to a Proxmox firewall source/destination matcher.")
|
conflicts.append(f"Rule {rule_index} cannot resolve {remote_ref} to a Proxmox firewall source/destination matcher.")
|
||||||
generated_rules.append({**mapped, "provider_target": provider_target})
|
for provider_target, _target_workload in targets:
|
||||||
|
generated_rules.append({**mapped, "provider_target": provider_target})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
ports = str(rule.get("ports", "any"))
|
ports = str(rule.get("ports", "any"))
|
||||||
protocol = str(rule.get("protocol", "any"))
|
protocol = str(rule.get("protocol", "any"))
|
||||||
protocols = ["tcp", "udp"] if protocol == "tcp/udp" else [protocol]
|
protocols = ["tcp", "udp"] if protocol == "tcp/udp" else [protocol]
|
||||||
for remote_value in remote_values:
|
for provider_target, target_workload in targets:
|
||||||
for provider_protocol in protocols:
|
for remote_value in remote_values:
|
||||||
provider_rule = {
|
for provider_protocol in protocols:
|
||||||
"type": "in" if direction == "ingress" else "out",
|
provider_rule = {
|
||||||
"action": proxmox_action(str(rule.get("action", "allow"))),
|
"type": "in" if direction == "ingress" else "out",
|
||||||
"enable": 1,
|
"action": proxmox_action(str(rule.get("action", "allow"))),
|
||||||
"comment": (
|
"enable": 1,
|
||||||
f"NexaFabric policy={rule.get('policy_id')} version={rule.get('policy_version')} "
|
"comment": (
|
||||||
f"rule={rule_index} target={target_workload.name}"
|
f"NexaFabric policy={rule.get('policy_id')} version={rule.get('policy_version')} "
|
||||||
),
|
f"rule={rule_index} target={target_workload.name}"
|
||||||
}
|
),
|
||||||
if provider_protocol != "any":
|
}
|
||||||
provider_rule["proto"] = provider_protocol
|
if provider_protocol != "any":
|
||||||
if ports != "any":
|
provider_rule["proto"] = provider_protocol
|
||||||
provider_rule["dport"] = ports
|
if ports != "any":
|
||||||
if remote_value:
|
provider_rule["dport"] = ports
|
||||||
provider_rule["source" if direction == "ingress" else "dest"] = remote_value
|
if remote_value:
|
||||||
if rule.get("logging"):
|
provider_rule["source" if direction == "ingress" else "dest"] = remote_value
|
||||||
provider_rule["log"] = "info"
|
if rule.get("logging"):
|
||||||
mapped_rule = {**mapped, "provider_target": provider_target, "provider_rule": provider_rule}
|
provider_rule["log"] = "info"
|
||||||
generated_rules.append(mapped_rule)
|
mapped_rule = {**mapped, "provider_target": provider_target, "provider_rule": provider_rule}
|
||||||
|
generated_rules.append(mapped_rule)
|
||||||
|
|
||||||
return FirewallPreview(
|
return FirewallPreview(
|
||||||
policy_id=preview.policy_id,
|
policy_id=preview.policy_id,
|
||||||
@@ -1131,6 +1199,8 @@ def delete_cluster(cluster_id: str, user: CurrentUser, db: Session = Depends(get
|
|||||||
raise HTTPException(status_code=404, detail="Cluster not found")
|
raise HTTPException(status_code=404, detail="Cluster not found")
|
||||||
workload_ids = [row[0] for row in db.execute(select(Workload.id).where(Workload.cluster_id == cluster.id)).all()]
|
workload_ids = [row[0] for row in db.execute(select(Workload.id).where(Workload.cluster_id == cluster.id)).all()]
|
||||||
if workload_ids:
|
if workload_ids:
|
||||||
|
for member in db.scalars(select(SecurityGroupMember).where(SecurityGroupMember.workload_id.in_(workload_ids))).all():
|
||||||
|
db.delete(member)
|
||||||
for address in db.scalars(select(IpAddress).where(IpAddress.workload_id.in_(workload_ids))).all():
|
for address in db.scalars(select(IpAddress).where(IpAddress.workload_id.in_(workload_ids))).all():
|
||||||
db.delete(address)
|
db.delete(address)
|
||||||
network_ids = [row[0] for row in db.execute(select(Network.id).where(Network.cluster_id == cluster.id)).all()]
|
network_ids = [row[0] for row in db.execute(select(Network.id).where(Network.cluster_id == cluster.id)).all()]
|
||||||
@@ -1862,8 +1932,36 @@ def create_project(payload: ProjectCreate, user: CurrentUser, db: Session = Depe
|
|||||||
|
|
||||||
|
|
||||||
@api_router.get("/security-groups", response_model=list[SecurityGroupRead])
|
@api_router.get("/security-groups", response_model=list[SecurityGroupRead])
|
||||||
def security_groups(_: CurrentUser, db: Session = Depends(get_db)) -> list[SecurityGroup]:
|
def security_groups(_: CurrentUser, db: Session = Depends(get_db)) -> list[dict[str, object]]:
|
||||||
return db.scalars(select(SecurityGroup).order_by(SecurityGroup.name)).all()
|
groups = db.scalars(select(SecurityGroup).order_by(SecurityGroup.name)).all()
|
||||||
|
members_by_group: dict[str, list[dict[str, object]]] = {group.id: [] for group in groups}
|
||||||
|
if groups:
|
||||||
|
rows = db.execute(
|
||||||
|
select(SecurityGroupMember, Workload)
|
||||||
|
.join(Workload, Workload.id == SecurityGroupMember.workload_id)
|
||||||
|
.where(SecurityGroupMember.security_group_id.in_([group.id for group in groups]))
|
||||||
|
.order_by(Workload.name)
|
||||||
|
).all()
|
||||||
|
for member, workload in rows:
|
||||||
|
members_by_group.setdefault(member.security_group_id, []).append(
|
||||||
|
{
|
||||||
|
"id": member.id,
|
||||||
|
"security_group_id": member.security_group_id,
|
||||||
|
"workload_id": member.workload_id,
|
||||||
|
"workload_name": workload.name,
|
||||||
|
"workload_external_id": workload.external_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": group.id,
|
||||||
|
"project_id": group.project_id,
|
||||||
|
"name": group.name,
|
||||||
|
"description": group.description,
|
||||||
|
"members": members_by_group.get(group.id, []),
|
||||||
|
}
|
||||||
|
for group in groups
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@api_router.post("/security-groups", response_model=SecurityGroupRead)
|
@api_router.post("/security-groups", response_model=SecurityGroupRead)
|
||||||
@@ -1876,6 +1974,50 @@ def create_security_group(payload: SecurityGroupCreate, user: CurrentUser, db: S
|
|||||||
return group
|
return group
|
||||||
|
|
||||||
|
|
||||||
|
def security_group_member_payload(db: Session, member: SecurityGroupMember) -> dict[str, object]:
|
||||||
|
workload = db.get(Workload, member.workload_id)
|
||||||
|
return {
|
||||||
|
"id": member.id,
|
||||||
|
"security_group_id": member.security_group_id,
|
||||||
|
"workload_id": member.workload_id,
|
||||||
|
"workload_name": workload.name if workload else None,
|
||||||
|
"workload_external_id": workload.external_id if workload else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api_router.post("/security-groups/{group_id}/members", response_model=SecurityGroupMemberRead)
|
||||||
|
def add_security_group_member(group_id: str, payload: SecurityGroupMemberCreate, user: CurrentUser, db: Session = Depends(get_db)) -> dict[str, object]:
|
||||||
|
if not db.get(SecurityGroup, group_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Security group not found")
|
||||||
|
if not db.get(Workload, payload.workload_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Workload not found")
|
||||||
|
existing = db.scalar(
|
||||||
|
select(SecurityGroupMember).where(
|
||||||
|
SecurityGroupMember.security_group_id == group_id,
|
||||||
|
SecurityGroupMember.workload_id == payload.workload_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
return security_group_member_payload(db, existing)
|
||||||
|
member = SecurityGroupMember(security_group_id=group_id, workload_id=payload.workload_id)
|
||||||
|
db.add(member)
|
||||||
|
commit_or_400(db)
|
||||||
|
db.refresh(member)
|
||||||
|
write_audit(db, action="security_group.member_added", object_type="security_group", object_id=group_id, user_id=user.id, new_values=payload.model_dump())
|
||||||
|
return security_group_member_payload(db, member)
|
||||||
|
|
||||||
|
|
||||||
|
@api_router.delete("/security-groups/{group_id}/members/{member_id}")
|
||||||
|
def delete_security_group_member(group_id: str, member_id: str, user: CurrentUser, db: Session = Depends(get_db)) -> dict[str, str]:
|
||||||
|
member = db.get(SecurityGroupMember, member_id)
|
||||||
|
if not member or member.security_group_id != group_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Security group member not found")
|
||||||
|
db.delete(member)
|
||||||
|
commit_or_400(db)
|
||||||
|
write_audit(db, action="security_group.member_removed", object_type="security_group", object_id=group_id, user_id=user.id, old_values={"member_id": member_id})
|
||||||
|
return {"status": "deleted", "id": member_id}
|
||||||
|
|
||||||
|
|
||||||
@api_router.get("/security-groups/{group_id}/rules", response_model=list[SecurityRuleRead])
|
@api_router.get("/security-groups/{group_id}/rules", response_model=list[SecurityRuleRead])
|
||||||
def security_group_rules(group_id: str, _: CurrentUser, db: Session = Depends(get_db)) -> list[SecurityRule]:
|
def security_group_rules(group_id: str, _: CurrentUser, db: Session = Depends(get_db)) -> list[SecurityRule]:
|
||||||
return db.scalars(
|
return db.scalars(
|
||||||
|
|||||||
@@ -231,6 +231,17 @@ class SecurityGroup(Base, TimestampMixin):
|
|||||||
description: Mapped[str | None] = mapped_column(Text)
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityGroupMember(Base, TimestampMixin):
|
||||||
|
__tablename__ = "security_group_members"
|
||||||
|
__table_args__ = (UniqueConstraint("security_group_id", "workload_id"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
||||||
|
security_group_id: Mapped[str] = mapped_column(ForeignKey("security_groups.id"), index=True)
|
||||||
|
workload_id: Mapped[str] = mapped_column(ForeignKey("workloads.id"), index=True)
|
||||||
|
security_group: Mapped[SecurityGroup] = relationship()
|
||||||
|
workload: Mapped[Workload] = relationship()
|
||||||
|
|
||||||
|
|
||||||
class SecurityRule(Base, TimestampMixin):
|
class SecurityRule(Base, TimestampMixin):
|
||||||
__tablename__ = "security_rules"
|
__tablename__ = "security_rules"
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,10 @@ class SecurityGroupCreate(BaseModel):
|
|||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityGroupMemberCreate(BaseModel):
|
||||||
|
workload_id: str
|
||||||
|
|
||||||
|
|
||||||
class SecurityRuleCreate(BaseModel):
|
class SecurityRuleCreate(BaseModel):
|
||||||
security_group_id: str
|
security_group_id: str
|
||||||
direction: str = "ingress"
|
direction: str = "ingress"
|
||||||
@@ -305,6 +309,15 @@ class SecurityGroupRead(OrmModel):
|
|||||||
project_id: str | None
|
project_id: str | None
|
||||||
name: str
|
name: str
|
||||||
description: str | None
|
description: str | None
|
||||||
|
members: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityGroupMemberRead(OrmModel):
|
||||||
|
id: str
|
||||||
|
security_group_id: str
|
||||||
|
workload_id: str
|
||||||
|
workload_name: str | None = None
|
||||||
|
workload_external_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class SecurityRuleRead(OrmModel):
|
class SecurityRuleRead(OrmModel):
|
||||||
|
|||||||
@@ -99,6 +99,15 @@ export type SecurityGroup = {
|
|||||||
project_id: string | null;
|
project_id: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
members: SecurityGroupMember[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SecurityGroupMember = {
|
||||||
|
id: string;
|
||||||
|
security_group_id: string;
|
||||||
|
workload_id: string;
|
||||||
|
workload_name: string | null;
|
||||||
|
workload_external_id: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SecurityRule = {
|
export type SecurityRule = {
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { inputClass } from "./FormControls";
|
||||||
|
|
||||||
|
export type SearchableOption = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
detail?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SearchableSelectProps = {
|
||||||
|
options: SearchableOption[];
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SearchableSelect({ options, value, onChange, placeholder = "Search..." }: SearchableSelectProps) {
|
||||||
|
const selected = options.find((option) => option.value === value);
|
||||||
|
const [query, setQuery] = useState(selected?.label ?? "");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const normalized = query.trim().toLowerCase();
|
||||||
|
if (!normalized || selected?.label === query) {
|
||||||
|
return options.slice(0, 12);
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
.filter((option) => `${option.label} ${option.detail ?? ""}`.toLowerCase().includes(normalized))
|
||||||
|
.slice(0, 12);
|
||||||
|
}, [options, query, selected?.label]);
|
||||||
|
|
||||||
|
function choose(option: SearchableOption) {
|
||||||
|
onChange(option.value);
|
||||||
|
setQuery(option.label);
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
value={open ? query : selected?.label ?? query}
|
||||||
|
onBlur={() => window.setTimeout(() => setOpen(false), 120)}
|
||||||
|
onChange={(event) => {
|
||||||
|
setQuery(event.target.value);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
setQuery(selected?.label ?? "");
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
{open ? (
|
||||||
|
<div className="absolute z-20 mt-1 max-h-64 w-full overflow-auto rounded-md border border-border bg-panel shadow-lg">
|
||||||
|
{filtered.length ? filtered.map((option) => (
|
||||||
|
<button
|
||||||
|
className="block w-full px-3 py-2 text-left text-sm hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||||
|
key={option.value}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
choose(option);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="block font-medium">{option.label}</span>
|
||||||
|
{option.detail ? <span className="block text-xs text-slate-500">{option.detail}</span> : null}
|
||||||
|
</button>
|
||||||
|
)) : <div className="px-3 py-2 text-sm text-slate-500">No matches.</div>}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { Save, Wand2 } from "lucide-react";
|
|||||||
import { api, Network, Policy, SecurityGroup, ServiceCatalogItem, Workload } from "../api/client";
|
import { api, Network, Policy, SecurityGroup, ServiceCatalogItem, Workload } from "../api/client";
|
||||||
import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
|
import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
import { SearchableSelect } from "../components/SearchableSelect";
|
||||||
|
|
||||||
type TargetOption = {
|
type TargetOption = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -47,7 +48,11 @@ export function PolicyDesigner() {
|
|||||||
{ label: "Any", value: "any" },
|
{ label: "Any", value: "any" },
|
||||||
{ label: "Custom IP/CIDR", value: customTargetValue },
|
{ label: "Custom IP/CIDR", value: customTargetValue },
|
||||||
...(workloads.data ?? []).map((workload) => ({ label: `VM/LXC: ${workload.name}`, value: `workload:${workload.id}` })),
|
...(workloads.data ?? []).map((workload) => ({ label: `VM/LXC: ${workload.name}`, value: `workload:${workload.id}` })),
|
||||||
...(securityGroups.data ?? []).map((group) => ({ label: `Security Group: ${group.name}`, value: `sg:${group.name}` })),
|
...(securityGroups.data ?? []).map((group) => ({
|
||||||
|
label: `Security Group: ${group.name}`,
|
||||||
|
value: `sg:${group.id}`,
|
||||||
|
detail: `${group.members?.length ?? 0} member${group.members?.length === 1 ? "" : "s"}`,
|
||||||
|
})),
|
||||||
...(networks.data ?? []).map((network) => ({ label: `Network: ${network.name}`, value: `network:${network.name}` })),
|
...(networks.data ?? []).map((network) => ({ label: `Network: ${network.name}`, value: `network:${network.name}` })),
|
||||||
];
|
];
|
||||||
}, [networks.data, securityGroups.data, workloads.data]);
|
}, [networks.data, securityGroups.data, workloads.data]);
|
||||||
@@ -126,13 +131,12 @@ export function PolicyDesigner() {
|
|||||||
</Field>
|
</Field>
|
||||||
<div />
|
<div />
|
||||||
<Field label="Source">
|
<Field label="Source">
|
||||||
<select
|
<SearchableSelect
|
||||||
className={selectClass}
|
options={targets}
|
||||||
value={endpointSelectValue(form.source, targets)}
|
value={endpointSelectValue(form.source, targets)}
|
||||||
onChange={(event) => setForm({ ...form, source: event.target.value === customTargetValue ? "" : event.target.value })}
|
onChange={(value) => setForm({ ...form, source: value === customTargetValue ? "" : value })}
|
||||||
>
|
placeholder="Search source..."
|
||||||
{targets.map((target) => <option key={target.value} value={target.value}>{target.label}</option>)}
|
/>
|
||||||
</select>
|
|
||||||
{isCustomEndpoint(form.source, targets) ? (
|
{isCustomEndpoint(form.source, targets) ? (
|
||||||
<input
|
<input
|
||||||
className={`${inputClass} mt-2`}
|
className={`${inputClass} mt-2`}
|
||||||
@@ -144,13 +148,12 @@ export function PolicyDesigner() {
|
|||||||
) : null}
|
) : null}
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Destination">
|
<Field label="Destination">
|
||||||
<select
|
<SearchableSelect
|
||||||
className={selectClass}
|
options={targets}
|
||||||
value={endpointSelectValue(form.destination, targets)}
|
value={endpointSelectValue(form.destination, targets)}
|
||||||
onChange={(event) => setForm({ ...form, destination: event.target.value === customTargetValue ? "" : event.target.value })}
|
onChange={(value) => setForm({ ...form, destination: value === customTargetValue ? "" : value })}
|
||||||
>
|
placeholder="Search destination..."
|
||||||
{targets.map((target) => <option key={target.value} value={target.value}>{target.label}</option>)}
|
/>
|
||||||
</select>
|
|
||||||
{isCustomEndpoint(form.destination, targets) ? (
|
{isCustomEndpoint(form.destination, targets) ? (
|
||||||
<input
|
<input
|
||||||
className={`${inputClass} mt-2`}
|
className={`${inputClass} mt-2`}
|
||||||
|
|||||||
@@ -1,19 +1,32 @@
|
|||||||
import { FormEvent, useMemo, useState } from "react";
|
import { FormEvent, useMemo, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Plus, Shield } from "lucide-react";
|
import { Plus, Shield, Trash2, UserPlus } from "lucide-react";
|
||||||
|
|
||||||
import { api, Project, SecurityGroup, SecurityRule } from "../api/client";
|
import { api, Project, SecurityGroup, SecurityGroupMember, SecurityRule, Workload } from "../api/client";
|
||||||
import { DataTable } from "../components/DataTable";
|
import { DataTable } from "../components/DataTable";
|
||||||
import { buttonClass, Field, inputClass, selectClass } from "../components/FormControls";
|
import { buttonClass, Field, iconButtonClass, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
|
||||||
import { Modal } from "../components/Modal";
|
import { Modal } from "../components/Modal";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
import { SearchableSelect, SearchableOption } from "../components/SearchableSelect";
|
||||||
|
|
||||||
export function SecurityGroups() {
|
export function SecurityGroups() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const projects = useQuery({ queryKey: ["projects"], queryFn: () => api<Project[]>("/projects") });
|
const projects = useQuery({ queryKey: ["projects"], queryFn: () => api<Project[]>("/projects") });
|
||||||
const groups = useQuery({ queryKey: ["security-groups"], queryFn: () => api<SecurityGroup[]>("/security-groups") });
|
const groups = useQuery({ queryKey: ["security-groups"], queryFn: () => api<SecurityGroup[]>("/security-groups") });
|
||||||
|
const workloads = useQuery({ queryKey: ["workloads"], queryFn: () => api<Workload[]>("/vms") });
|
||||||
const [selectedGroupId, setSelectedGroupId] = useState("");
|
const [selectedGroupId, setSelectedGroupId] = useState("");
|
||||||
const selectedGroup = useMemo(() => selectedGroupId || groups.data?.[0]?.id || "", [groups.data, selectedGroupId]);
|
const selectedGroup = useMemo(() => selectedGroupId || groups.data?.[0]?.id || "", [groups.data, selectedGroupId]);
|
||||||
|
const selectedGroupRecord = useMemo(() => (groups.data ?? []).find((group) => group.id === selectedGroup), [groups.data, selectedGroup]);
|
||||||
|
const memberOptions = useMemo<SearchableOption[]>(() => {
|
||||||
|
const existing = new Set((selectedGroupRecord?.members ?? []).map((member) => member.workload_id));
|
||||||
|
return (workloads.data ?? [])
|
||||||
|
.filter((workload) => !existing.has(workload.id))
|
||||||
|
.map((workload) => ({
|
||||||
|
label: workload.name,
|
||||||
|
value: workload.id,
|
||||||
|
detail: `${workload.kind} · VMID ${workload.external_id} · ${workload.status}`,
|
||||||
|
}));
|
||||||
|
}, [selectedGroupRecord?.members, workloads.data]);
|
||||||
const rules = useQuery({
|
const rules = useQuery({
|
||||||
queryKey: ["security-rules", selectedGroup],
|
queryKey: ["security-rules", selectedGroup],
|
||||||
queryFn: () => api<SecurityRule[]>(`/security-groups/${selectedGroup}/rules`),
|
queryFn: () => api<SecurityRule[]>(`/security-groups/${selectedGroup}/rules`),
|
||||||
@@ -33,6 +46,8 @@ export function SecurityGroups() {
|
|||||||
});
|
});
|
||||||
const [groupOpen, setGroupOpen] = useState(false);
|
const [groupOpen, setGroupOpen] = useState(false);
|
||||||
const [ruleOpen, setRuleOpen] = useState(false);
|
const [ruleOpen, setRuleOpen] = useState(false);
|
||||||
|
const [memberOpen, setMemberOpen] = useState(false);
|
||||||
|
const [memberWorkloadId, setMemberWorkloadId] = useState("");
|
||||||
|
|
||||||
const createGroup = useMutation({
|
const createGroup = useMutation({
|
||||||
mutationFn: () => api<SecurityGroup>("/security-groups", { method: "POST", body: JSON.stringify({ ...groupForm, project_id: groupForm.project_id || null }) }),
|
mutationFn: () => api<SecurityGroup>("/security-groups", { method: "POST", body: JSON.stringify({ ...groupForm, project_id: groupForm.project_id || null }) }),
|
||||||
@@ -48,6 +63,18 @@ export function SecurityGroups() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["security-rules", selectedGroup] });
|
queryClient.invalidateQueries({ queryKey: ["security-rules", selectedGroup] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const addMember = useMutation({
|
||||||
|
mutationFn: () => api<SecurityGroupMember>(`/security-groups/${selectedGroup}/members`, { method: "POST", body: JSON.stringify({ workload_id: memberWorkloadId }) }),
|
||||||
|
onSuccess: () => {
|
||||||
|
setMemberOpen(false);
|
||||||
|
setMemberWorkloadId("");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["security-groups"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const removeMember = useMutation({
|
||||||
|
mutationFn: (member: SecurityGroupMember) => api<{ status: string }>(`/security-groups/${member.security_group_id}/members/${member.id}`, { method: "DELETE" }),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["security-groups"] }),
|
||||||
|
});
|
||||||
|
|
||||||
async function submitGroup(event: FormEvent) {
|
async function submitGroup(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -67,6 +94,7 @@ export function SecurityGroups() {
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<button className={buttonClass} onClick={() => setGroupOpen(true)}><Plus size={16} /> Add Group</button>
|
<button className={buttonClass} onClick={() => setGroupOpen(true)}><Plus size={16} /> Add Group</button>
|
||||||
<button className={buttonClass} onClick={() => setRuleOpen(true)} disabled={!selectedGroup}><Plus size={16} /> Add Rule</button>
|
<button className={buttonClass} onClick={() => setRuleOpen(true)} disabled={!selectedGroup}><Plus size={16} /> Add Rule</button>
|
||||||
|
<button className={secondaryButtonClass} onClick={() => setMemberOpen(true)} disabled={!selectedGroup || !memberOptions.length}><UserPlus size={16} /> Add Member</button>
|
||||||
</div>
|
</div>
|
||||||
<Modal title="Add Security Group" open={groupOpen} onClose={() => setGroupOpen(false)}>
|
<Modal title="Add Security Group" open={groupOpen} onClose={() => setGroupOpen(false)}>
|
||||||
<form onSubmit={submitGroup}>
|
<form onSubmit={submitGroup}>
|
||||||
@@ -84,6 +112,26 @@ export function SecurityGroups() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
<Modal title="Add Group Member" open={memberOpen} onClose={() => setMemberOpen(false)}>
|
||||||
|
<form
|
||||||
|
onSubmit={async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await addMember.mutateAsync();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-center gap-2 font-medium"><UserPlus size={18} /> Add Member</div>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div className="rounded-md border border-border bg-canvas p-3 text-sm">
|
||||||
|
<div className="text-xs text-slate-500">Security Group</div>
|
||||||
|
<div className="mt-1 font-medium">{selectedGroupRecord?.name ?? "No group selected"}</div>
|
||||||
|
</div>
|
||||||
|
<Field label="VM/LXC">
|
||||||
|
<SearchableSelect options={memberOptions} value={memberWorkloadId} onChange={setMemberWorkloadId} placeholder="Search VM/LXC..." />
|
||||||
|
</Field>
|
||||||
|
<button className={buttonClass} disabled={!memberWorkloadId || addMember.isPending}><Plus size={16} /> Add Member</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
<Modal title="Add Security Rule" open={ruleOpen} onClose={() => setRuleOpen(false)}>
|
<Modal title="Add Security Rule" open={ruleOpen} onClose={() => setRuleOpen(false)}>
|
||||||
<form onSubmit={submitRule}>
|
<form onSubmit={submitRule}>
|
||||||
<div className="mb-4 font-medium">Add Rule</div>
|
<div className="mb-4 font-medium">Add Rule</div>
|
||||||
@@ -108,7 +156,66 @@ export function SecurityGroups() {
|
|||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<DataTable rows={(groups.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "name", label: "Group" }, { key: "description", label: "Description" }]} />
|
<DataTable
|
||||||
|
rows={(groups.data ?? []) as unknown as Record<string, unknown>[]}
|
||||||
|
selectedId={selectedGroup}
|
||||||
|
onRowClick={(row) => setSelectedGroupId(String(row.id))}
|
||||||
|
columns={[
|
||||||
|
{ key: "name", label: "Group" },
|
||||||
|
{ key: "description", label: "Description" },
|
||||||
|
{
|
||||||
|
key: "members",
|
||||||
|
label: "Members",
|
||||||
|
render: (row) => {
|
||||||
|
const group = row as unknown as SecurityGroup;
|
||||||
|
const members = group.members ?? [];
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{members.slice(0, 5).map((member) => (
|
||||||
|
<span key={member.id} className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs text-slate-500">
|
||||||
|
{member.workload_name ?? member.workload_id}
|
||||||
|
<button
|
||||||
|
className="text-slate-400 hover:text-danger"
|
||||||
|
disabled={removeMember.isPending}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
removeMember.mutate(member);
|
||||||
|
}}
|
||||||
|
title="Remove member"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{members.length > 5 ? <span className="rounded-md border border-border px-2 py-1 text-xs text-slate-500">+{members.length - 5}</span> : null}
|
||||||
|
{!members.length ? <span className="text-xs text-slate-500">No members</span> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "Actions",
|
||||||
|
render: (row) => (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
className={iconButtonClass}
|
||||||
|
title="Add member"
|
||||||
|
aria-label="Add member"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setSelectedGroupId(String(row.id));
|
||||||
|
setMemberOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserPlus size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
<DataTable rows={(rules.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "priority", label: "Priority" }, { key: "direction", label: "Direction" }, { key: "action", label: "Action" }, { key: "protocol", label: "Protocol" }, { key: "port", label: "Port" }]} />
|
<DataTable rows={(rules.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "priority", label: "Priority" }, { key: "direction", label: "Direction" }, { key: "action", label: "Action" }, { key: "protocol", label: "Protocol" }, { key: "port", label: "Port" }]} />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user