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
9 changes: 7 additions & 2 deletions frontend/src/components/ClientsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Trash2, Copy, RefreshCw, Settings, KeyRound, GitMerge } from 'lucide-react'
import toast from 'react-hot-toast'
import api, { toArr } from '@/lib/api'
import { Badge, Button, Modal, Input, PageHeader, EmptyState, Spinner } from '@/components/ui'
import { Badge, Button, Modal, Input, PageHeader, EmptyState, Spinner, ManagedBadge } from '@/components/ui'

interface OIDCClient {
client_id: string
Expand All @@ -17,6 +17,8 @@ interface OIDCClient {
keycloak_compat: boolean
enabled_login_providers: string[]
created_at: string
managed_by?: string | null
managed_ref?: string | null
}

interface ProtocolMapper {
Expand Down Expand Up @@ -437,7 +439,10 @@ export default function ClientsPage({ orgId, breadcrumb }: Props) {
<KeyRound style={{ width: 14, height: 14, color: 'var(--clavex-700)' }} />
</div>
<div style={{ minWidth: 0 }}>
<p style={{ fontWeight: 600, color: 'var(--clavex-ink)', fontSize: 13, lineHeight: 1.2 }}>{c.name}</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<p style={{ fontWeight: 600, color: 'var(--clavex-ink)', fontSize: 13, lineHeight: 1.2 }}>{c.name}</p>
<ManagedBadge managedBy={c.managed_by} managedRef={c.managed_ref} />
</div>
<p style={{ fontSize: 10, color: 'var(--clavex-neutral)', fontFamily: 'monospace', marginTop: 1 }} className="truncate">{c.client_id}</p>
</div>
</div>
Expand Down
11 changes: 9 additions & 2 deletions frontend/src/components/IdentityProvidersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Pencil, Trash2, Globe, X } from 'lucide-react'
import toast from 'react-hot-toast'
import api from '@/lib/api'
import { Button, Card, PageHeader, Spinner, Modal } from '@/components/ui'
import { Button, Card, PageHeader, Spinner, Modal, ManagedBadge } from '@/components/ui'

// ── Types ──────────────────────────────────────────────────────────────────────

Expand All @@ -26,6 +26,8 @@ interface IDP {
role_claim_mappings: Record<string, string>
apple_team_id?: string | null
apple_key_id?: string | null
managed_by?: string | null
managed_ref?: string | null
}

interface IDPForm {
Expand Down Expand Up @@ -550,7 +552,12 @@ export default function IdentityProvidersPage({ orgId }: Props) {
<tbody>
{providers.map((idp) => (
<tr key={idp.id} className="border-b border-gray-50 hover:bg-gray-50/50 transition-colors">
<td className="px-4 py-3 font-medium text-gray-900">{idp.name}</td>
<td className="px-4 py-3 font-medium text-gray-900">
<div className="flex items-center gap-1.5">
<span>{idp.name}</span>
<ManagedBadge managedBy={idp.managed_by} managedRef={idp.managed_ref} />
</div>
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded text-xs font-medium bg-blue-50 text-blue-700">
<ProviderIcon type={idp.provider_type} className="h-3 w-3" />
Expand Down
9 changes: 7 additions & 2 deletions frontend/src/components/RolesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Trash2, ChevronDown, ChevronRight, GitMerge } from 'lucide-react'
import toast from 'react-hot-toast'
import api, { toArr } from '@/lib/api'
import { Button, Card, Modal, Input, PageHeader, EmptyState, Spinner } from '@/components/ui'
import { Button, Card, Modal, Input, PageHeader, EmptyState, Spinner, ManagedBadge } from '@/components/ui'

interface Role {
id: string
name: string
description?: string
is_system: boolean
created_at: string
managed_by?: string | null
managed_ref?: string | null
}

interface Props {
Expand Down Expand Up @@ -181,7 +183,10 @@ export default function RolesPage({ orgId, breadcrumb }: Props) {
}
</button>
<div className="min-w-0">
<p className="font-medium text-gray-900 text-sm">{role.name}</p>
<div className="flex items-center gap-1.5">
<p className="font-medium text-gray-900 text-sm">{role.name}</p>
<ManagedBadge managedBy={role.managed_by} managedRef={role.managed_ref} />
</div>
{role.description && (
<p className="text-xs text-gray-400 mt-0.5">{role.description}</p>
)}
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/components/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'
import { useQuery, useMutation } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import api from '@/lib/api'
import { Button, Input, Card, PageHeader, Spinner } from '@/components/ui'
import { Button, Input, Card, PageHeader, Spinner, ManagedBadge } from '@/components/ui'

// ── Types ──────────────────────────────────────────────────────────────────────

Expand All @@ -15,6 +15,8 @@ interface PasswordPolicy {
max_age_days: number | null
prevent_reuse_count: number
breached_password_action: string
managed_by?: string | null
managed_ref?: string | null
}

interface SMTPSettings {
Expand Down Expand Up @@ -63,6 +65,11 @@ function PasswordPolicyTab({ orgId }: { orgId: string }) {

return (
<Card className="p-6 space-y-6">
{data?.managed_by && (
<div className="flex items-center">
<ManagedBadge managedBy={data.managed_by} managedRef={data.managed_ref} />
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/components/WebhooksPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api, { toArr } from '@/lib/api'
import toast from 'react-hot-toast'
import { Plus, Trash2, Pencil, Webhook, Copy, Check, History, RefreshCw, X } from 'lucide-react'
import { ManagedBadge } from '@/components/ui'

interface WebhookEntry {
id: string
url: string
events: string[]
is_active: boolean
created_at: string
managed_by?: string | null
managed_ref?: string | null
}

interface CreatedWebhook extends WebhookEntry {
Expand Down Expand Up @@ -317,7 +320,10 @@ export default function WebhooksPage({ orgId }: Props) {
{webhooks.map(wh => (
<div key={wh.id} className="flex items-center gap-4 bg-white rounded-xl border border-gray-200 px-5 py-4">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{wh.url}</p>
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium text-gray-900 truncate">{wh.url}</p>
<ManagedBadge managedBy={wh.managed_by} managedRef={wh.managed_ref} />
</div>
<div className="flex flex-wrap gap-1.5 mt-1.5">
{wh.events.map(ev => (
<span key={ev} className="text-xs rounded-full px-2 py-0.5 bg-indigo-50 text-indigo-700 font-mono">{ev}</span>
Expand Down
23 changes: 23 additions & 0 deletions frontend/src/components/ui/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,29 @@ export function Badge({ variant = 'gray', children, className }: BadgeProps) {
)
}

// ── ManagedBadge ────────────────────────────────────────────────────────────────
// Shown next to a resource that is owned by an external declarative system
// (today: the Kubernetes operator, managed_by="k8s-operator"). Renders nothing
// when the resource is hand-managed. The native title tooltip warns that
// out-of-band edits here will be reverted at the next reconcile.
interface ManagedBadgeProps { managedBy?: string | null; managedRef?: string | null; className?: string }
export function ManagedBadge({ managedBy, managedRef, className }: ManagedBadgeProps) {
if (!managedBy) return null
const label = managedBy === 'k8s-operator'
? 'Managed by Kubernetes Operator'
: `Managed by ${managedBy}`
const via = managedRef ? ` via ${managedRef}` : ''
const tooltip = `This resource is managed declaratively${via}. Changes made here will be reverted automatically. Edit the Kubernetes resource instead.`
return (
<span title={tooltip} className={cn('cursor-help', className)}>
<Badge variant="blue" className="gap-1">
<span aria-hidden="true">⚙</span>
{label}
</Badge>
</span>
)
}

// ── Button ────────────────────────────────────────────────────────────────────
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
Expand Down
9 changes: 7 additions & 2 deletions frontend/src/pages/tenant/Groups.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Trash2, Users, Shield, UserPlus, X } from 'lucide-react'
import toast from 'react-hot-toast'
import { useAuthStore } from '@/stores/auth'
import { Button, Card, Modal, Input, PageHeader, Badge, Spinner } from '@/components/ui'
import { Button, Card, Modal, Input, PageHeader, Badge, Spinner, ManagedBadge } from '@/components/ui'
import { cn } from '@/lib/cn'
import api, { toArr } from '@/lib/api'

Expand All @@ -14,6 +14,8 @@ interface Group {
is_system: boolean
member_count: number
created_at: string
managed_by?: string | null
managed_ref?: string | null
}

interface User {
Expand Down Expand Up @@ -189,7 +191,10 @@ export default function GroupsPage() {
<span className="text-xs font-bold text-gray-500">{g.name[0].toUpperCase()}</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{g.name}</p>
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium text-gray-900 truncate">{g.name}</p>
<ManagedBadge managedBy={g.managed_by} managedRef={g.managed_ref} />
</div>
<p className="text-xs text-gray-400">{g.member_count} member{g.member_count !== 1 ? 's' : ''}</p>
</div>
{g.is_system && (
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/pages/tenant/PolicyEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useState, useCallback, useRef } from 'react'
import { useAuthStore } from '@/stores/auth'
import api, { toArr } from '@/lib/api'
import { ManagedBadge } from '@/components/ui'
import toast from 'react-hot-toast'
import {
Plus, Trash2, Play, CheckCircle, XCircle, AlertCircle,
Expand All @@ -26,6 +27,8 @@ interface PolicyRule {
enabled: boolean
action: Action
conditions: Record<string, unknown>
managed_by?: string | null
managed_ref?: string | null
_localConditions?: Condition[] // builder state, not sent to API
}

Expand Down Expand Up @@ -414,6 +417,7 @@ function RuleCard({ rule, onUpdate, onDelete, onSimulate }: {
onChange={(e) => onUpdate({ ...rule, name: e.target.value })}
placeholder="Rule name…"
/>
<ManagedBadge managedBy={rule.managed_by} managedRef={rule.managed_ref} />
<span className="text-xs px-2 py-0.5 rounded-full font-semibold" style={{ background: `${actionDef?.color}22`, color: actionDef?.color }}>
{actionDef?.label ?? rule.action}
</span>
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/pages/tenant/RateLimits.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import { useAuthStore } from '@/stores/auth'
import api from '@/lib/api'
import toast from 'react-hot-toast'
import { Gauge, Plus, Trash2 } from 'lucide-react'
import { Button, Card, Input, PageHeader, Spinner } from '@/components/ui'
import { Button, Card, Input, PageHeader, Spinner, ManagedBadge } from '@/components/ui'

interface RateLimits {
login_per_ip_per_min: number
token_per_client_per_min: number
global_per_ip_per_min: number
endpoint_limits: Record<string, number> | null
managed_by?: string | null
managed_ref?: string | null
}

export default function RateLimitsPage() {
Expand Down Expand Up @@ -77,6 +79,7 @@ export default function RateLimitsPage() {
<PageHeader
title="Rate Limits"
subtitle="Control request throttling for login, token, and custom endpoints."
action={<ManagedBadge managedBy={data?.managed_by} managedRef={data?.managed_ref} />}
/>

<Card className="p-6 space-y-5">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
ALTER TABLE oidc_clients DROP COLUMN IF EXISTS managed_by, DROP COLUMN IF EXISTS managed_ref;
ALTER TABLE roles DROP COLUMN IF EXISTS managed_by, DROP COLUMN IF EXISTS managed_ref;
ALTER TABLE groups DROP COLUMN IF EXISTS managed_by, DROP COLUMN IF EXISTS managed_ref;
ALTER TABLE org_auth_policies DROP COLUMN IF EXISTS managed_by, DROP COLUMN IF EXISTS managed_ref;
ALTER TABLE webhooks DROP COLUMN IF EXISTS managed_by, DROP COLUMN IF EXISTS managed_ref;
ALTER TABLE identity_providers DROP COLUMN IF EXISTS managed_by, DROP COLUMN IF EXISTS managed_ref;
ALTER TABLE org_password_policy DROP COLUMN IF EXISTS managed_by, DROP COLUMN IF EXISTS managed_ref;
ALTER TABLE org_rate_limits DROP COLUMN IF EXISTS managed_by, DROP COLUMN IF EXISTS managed_ref;
34 changes: 34 additions & 0 deletions internal/db/migrations/000179_operator_managed_marker.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
-- Declarative-management marker for resources owned by an external system
-- (today: the Clavex Kubernetes operator, k8s-operator). When a controller
-- creates or updates one of these resources it stamps managed_by/managed_ref
-- via the X-Clavex-Managed-By / X-Clavex-Managed-Ref request headers, so the
-- console and API can warn admins that out-of-band edits will be reverted at
-- the next reconcile.
--
-- Both columns are nullable and default NULL: an unmanaged resource (the vast
-- majority) carries no marker. managed_by is intentionally free-form text
-- ("k8s-operator" for now) rather than a boolean or enum, so future
-- declarative sources (GitOps, Terraform, ...) can reuse the same columns.
-- managed_ref is a human-readable pointer to the owning object
-- (e.g. "ClavexClient/clavex-operator-system/testclient" —
-- Kind/namespace/name of the CR) for debugging and a future deep link.
--
-- Marking rules live in the update handlers: only a request carrying the
-- X-Clavex-Managed-By header sets the columns; an ordinary update (UI,
-- clavexctl, direct API) never clears an existing marker. Management is
-- released explicitly (X-Clavex-Managed-Release header / the operator's
-- disown path), which clears the columns without touching the resource's
-- own configuration.

ALTER TABLE oidc_clients ADD COLUMN managed_by TEXT, ADD COLUMN managed_ref TEXT;
ALTER TABLE roles ADD COLUMN managed_by TEXT, ADD COLUMN managed_ref TEXT;
ALTER TABLE groups ADD COLUMN managed_by TEXT, ADD COLUMN managed_ref TEXT;
ALTER TABLE org_auth_policies ADD COLUMN managed_by TEXT, ADD COLUMN managed_ref TEXT;
ALTER TABLE webhooks ADD COLUMN managed_by TEXT, ADD COLUMN managed_ref TEXT;
ALTER TABLE identity_providers ADD COLUMN managed_by TEXT, ADD COLUMN managed_ref TEXT;

-- ClavexOrg does not own a single "organizations" row; it reconciles the
-- org's password policy and rate-limit sections independently (either may be
-- managed while the other is not), so the marker lives on each section's row.
ALTER TABLE org_password_policy ADD COLUMN managed_by TEXT, ADD COLUMN managed_ref TEXT;
ALTER TABLE org_rate_limits ADD COLUMN managed_by TEXT, ADD COLUMN managed_ref TEXT;
14 changes: 14 additions & 0 deletions internal/handler/clients.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ func (h *ClientHandler) Create(c echo.Context) error {
if err != nil {
return err
}
if mk := managedMarkerFromRequest(c); mk.By != "" {
if err := h.repo.SetManagedMarker(c.Request().Context(), client.ClientID, orgID, mk); err != nil {
log.Error().Err(err).Str("client_id", client.ClientID).Msg("clients: SetManagedMarker failed")
return echo.ErrInternalServerError
}
reflectManagedMarker(&client.ManagedMarker, mk)
}
emitEntityAudit(c, h.auditor, orgID, "oidc_client.created", auditResourceOIDCClient, client.ClientID,
map[string]interface{}{"name": client.Name})
// Return the plain-text secret only at creation time, never again.
Expand Down Expand Up @@ -161,6 +168,13 @@ func (h *ClientHandler) Update(c echo.Context) error {
}
client.EnabledLoginProviders = req.EnabledLoginProviders
}
if mk := managedMarkerFromRequest(c); mk.Active() {
if err := h.repo.SetManagedMarker(c.Request().Context(), client.ClientID, orgID, mk); err != nil {
log.Error().Err(err).Str("client_id", client.ClientID).Msg("clients: SetManagedMarker failed")
return echo.ErrInternalServerError
}
reflectManagedMarker(&client.ManagedMarker, mk)
}
emitEntityAudit(c, h.auditor, orgID, "oidc_client.updated", auditResourceOIDCClient, client.ClientID,
map[string]interface{}{"name": client.Name})
return c.JSON(http.StatusOK, client)
Expand Down
8 changes: 8 additions & 0 deletions internal/handler/groups.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,14 @@ func (h *GroupHandler) Create(c echo.Context) error {
if err != nil {
return err
}
// Groups have no update route (create/delete + membership only), so the
// operator stamps its marker here on create.
if mk := managedMarkerFromRequest(c); mk.By != "" {
if err := h.repo.SetManagedMarker(c.Request().Context(), g.ID, orgID, mk); err != nil {
return echo.ErrInternalServerError
}
reflectManagedMarker(&g.ManagedMarker, mk)
}
emitEntityAudit(c, h.auditor, orgID, "group.created", auditResourceGroup, req.Name, nil)
return c.JSON(http.StatusCreated, g)
}
Expand Down
12 changes: 12 additions & 0 deletions internal/handler/identity_providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,12 @@ func (h *IDPHandler) Create(c echo.Context) error {
if err != nil {
return echo.NewHTTPError(http.StatusConflict, "provider name already exists for this organization")
}
if mk := managedMarkerFromRequest(c); mk.By != "" {
if err := h.repo.SetManagedMarker(c.Request().Context(), created.ID, orgID, mk); err != nil {
return echo.ErrInternalServerError
}
reflectManagedMarker(&created.ManagedMarker, mk)
}
emitEntityAudit(c, h.auditor, orgID, "identity_provider.created", auditResourceIdentityProvider, req.Name, nil)
return c.JSON(http.StatusCreated, created)
}
Expand Down Expand Up @@ -290,6 +296,12 @@ func (h *IDPHandler) Update(c echo.Context) error {
if err != nil {
return echo.ErrNotFound
}
if mk := managedMarkerFromRequest(c); mk.Active() {
if err := h.repo.SetManagedMarker(c.Request().Context(), updated.ID, orgID, mk); err != nil {
return echo.ErrInternalServerError
}
reflectManagedMarker(&updated.ManagedMarker, mk)
}
emitEntityAudit(c, h.auditor, orgID, "identity_provider.updated", auditResourceIdentityProvider, req.Name, nil)
return c.JSON(http.StatusOK, updated)
}
Expand Down
Loading
Loading