Compare commits

...

2 Commits

Author SHA1 Message Date
allanice001
85f37cd113 fix: ui updates for org api keys
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-12-12 02:05:31 +00:00
allanice001
fd1a81ecd8 fix: api keys form bugfix and org key sweeper job
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-12-12 01:37:42 +00:00
11 changed files with 295 additions and 95 deletions

View File

@@ -156,6 +156,21 @@ var serveCmd = &cobra.Command{
if err != nil {
log.Printf("failed to enqueue cluster bootstrap: %v", err)
}
_, err = jobs.Enqueue(
context.Background(),
uuid.NewString(),
"org_key_sweeper",
bg.OrgKeySweeperArgs{
IntervalS: 3600,
RetentionDays: 10,
},
archer.WithMaxRetries(1),
archer.WithScheduleTime(time.Now()),
)
if err != nil {
log.Printf("failed to enqueue org_key_sweeper: %v", err)
}
}
_ = auth.Refresh(rt.DB, rt.Cfg.JWTPrivateEncKey)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -104,6 +104,8 @@ components:
$ref: '#/components/schemas/dto.LoadBalancerResponse'
id:
type: string
kubeconfig:
type: string
last_error:
type: string
name:
@@ -113,6 +115,10 @@ components:
$ref: '#/components/schemas/dto.NodePoolResponse'
type: array
uniqueItems: false
org_key:
type: string
org_secret:
type: string
random_token:
type: string
region:
@@ -1037,6 +1043,8 @@ components:
type: object
models.APIKey:
properties:
cluster_id:
type: string
created_at:
format: date-time
type: string
@@ -1046,6 +1054,8 @@ components:
id:
format: uuid
type: string
is_ephemeral:
type: boolean
last_used_at:
format: date-time
type: string
@@ -1056,6 +1066,8 @@ components:
type: string
prefix:
type: string
purpose:
type: string
revoked:
type: boolean
scope:

View File

@@ -129,6 +129,12 @@ func NewJobs(gdb *gorm.DB, dbUrl string) (*Jobs, error) {
archer.WithTimeout(60*time.Minute),
)
c.Register(
"org_key_sweeper",
OrgKeySweeperWorker(gdb, jobs),
archer.WithInstances(1),
archer.WithTimeout(5*time.Minute),
)
return jobs, nil
}

View File

@@ -0,0 +1,95 @@
package bg
import (
"context"
"time"
"github.com/dyaksa/archer"
"github.com/dyaksa/archer/job"
"github.com/glueops/autoglue/internal/models"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
)
type OrgKeySweeperArgs struct {
IntervalS int `json:"interval_seconds,omitempty"`
RetentionDays int `json:"retention_days,omitempty"`
}
type OrgKeySweeperResult struct {
Status string `json:"status"`
MarkedRevoked int `json:"marked_revoked"`
DeletedEphemeral int `json:"deleted_ephemeral"`
ElapsedMs int `json:"elapsed_ms"`
}
func OrgKeySweeperWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
return func(ctx context.Context, j job.Job) (any, error) {
args := OrgKeySweeperArgs{
IntervalS: 3600,
RetentionDays: 10,
}
start := time.Now()
_ = j.ParseArguments(&args)
if args.IntervalS <= 0 {
args.IntervalS = 3600
}
if args.RetentionDays <= 0 {
args.RetentionDays = 10
}
now := time.Now()
// 1) Mark expired keys as revoked
res1 := db.Model(&models.APIKey{}).
Where("expires_at IS NOT NULL AND expires_at <= ? AND revoked = false", now).
Updates(map[string]any{
"revoked": true,
"updated_at": now,
})
if res1.Error != nil {
log.Error().Err(res1.Error).Msg("[org_key_sweeper] mark expired revoked failed")
return nil, res1.Error
}
markedRevoked := int(res1.RowsAffected)
// 2) Hard-delete ephemeral keys that are revoked and older than retention
cutoff := now.Add(-time.Duration(args.RetentionDays) * 24 * time.Hour)
res2 := db.
Where("is_ephemeral = ? AND revoked = ? AND updated_at <= ?", true, true, cutoff).
Delete(&models.APIKey{})
if res2.Error != nil {
log.Error().Err(res2.Error).Msg("[org_key_sweeper] delete revoked ephemeral keys failed")
return nil, res2.Error
}
deletedEphemeral := int(res2.RowsAffected)
out := OrgKeySweeperResult{
Status: "ok",
MarkedRevoked: markedRevoked,
DeletedEphemeral: deletedEphemeral,
ElapsedMs: int(time.Since(start).Milliseconds()),
}
log.Info().
Int("marked_revoked", markedRevoked).
Int("deleted_ephemeral", deletedEphemeral).
Msg("[org_key_sweeper] cleanup tick ok")
// Re-enqueue the sweeper
next := time.Now().Add(time.Duration(args.IntervalS) * time.Second)
_, _ = jobs.Enqueue(
ctx,
uuid.NewString(),
"org_key_sweeper",
args,
archer.WithScheduleTime(next),
archer.WithMaxRetries(1),
)
return out, nil
}
}

View File

@@ -158,10 +158,11 @@ func ClusterPrepareWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
dtoCluster.Kubeconfig = &kubeconfig
}
orgKey, orgSecret, err := createOrgScopedKeyForPayload(
orgKey, orgSecret, err := findOrCreateClusterAutomationKey(
db,
c.OrganizationID,
fmt.Sprintf("cluster-%s-%s", c.Name, c.ID.String()),
c.ID,
24*time.Hour,
)
if err != nil {
@@ -584,12 +585,28 @@ func randomB64URL(n int) (string, error) {
return base64.RawURLEncoding.EncodeToString(b), nil
}
func createOrgScopedKeyForPayload(db *gorm.DB, orgID uuid.UUID, name string) (orgKey string, orgSecret string, err error) {
func findOrCreateClusterAutomationKey(
db *gorm.DB,
orgID uuid.UUID,
clusterID uuid.UUID,
ttl time.Duration,
) (orgKey string, orgSecret string, err error) {
now := time.Now()
name := fmt.Sprintf("cluster-%s-bastion", clusterID.String())
// 1) Delete any existing ephemeral cluster-bastion key for this org+cluster
if err := db.Where(
"org_id = ? AND scope = ? AND purpose = ? AND cluster_id = ? AND is_ephemeral = ?",
orgID, "org", "cluster_bastion", clusterID, true,
).Delete(&models.APIKey{}).Error; err != nil {
return "", "", fmt.Errorf("delete existing cluster key: %w", err)
}
// 2) Mint a fresh keypair
keySuffix, err := randomB64URL(16)
if err != nil {
return "", "", fmt.Errorf("entropy_error: %w", err)
}
sec, err := randomB64URL(32)
if err != nil {
return "", "", fmt.Errorf("entropy_error: %w", err)
@@ -604,13 +621,25 @@ func createOrgScopedKeyForPayload(db *gorm.DB, orgID uuid.UUID, name string) (or
return "", "", fmt.Errorf("hash_error: %w", err)
}
exp := now.Add(ttl)
prefix := orgKey
if len(prefix) > 12 {
prefix = prefix[:12]
}
rec := models.APIKey{
OrgID: &orgID,
Scope: "org",
Name: name,
KeyHash: keyHash,
SecretHash: &secretHash,
ExpiresAt: nil,
OrgID: &orgID,
Scope: "org",
Purpose: "cluster_bastion",
ClusterID: &clusterID,
IsEphemeral: true,
Name: name,
KeyHash: keyHash,
SecretHash: &secretHash,
ExpiresAt: &exp,
Revoked: false,
Prefix: &prefix,
}
if err := db.Create(&rec).Error; err != nil {

View File

@@ -585,13 +585,22 @@ func CreateOrgKey(db *gorm.DB) http.HandlerFunc {
exp = &e
}
prefix := orgKey
if len(prefix) > 12 {
prefix = prefix[:12]
}
rec := models.APIKey{
OrgID: &oid,
Scope: "org",
Name: req.Name,
KeyHash: keyHash,
SecretHash: &secretHash,
ExpiresAt: exp,
OrgID: &oid,
Scope: "org",
Purpose: "user",
IsEphemeral: false,
Name: req.Name,
KeyHash: keyHash,
SecretHash: &secretHash,
ExpiresAt: exp,
Revoked: false,
Prefix: &prefix,
}
if err := db.Create(&rec).Error; err != nil {
utils.WriteError(w, 500, "db_error", err.Error())

View File

@@ -7,17 +7,20 @@ import (
)
type APIKey struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
Name string `gorm:"not null;default:''" json:"name"`
KeyHash string `gorm:"uniqueIndex;not null" json:"-"`
Scope string `gorm:"not null;default:''" json:"scope"`
UserID *uuid.UUID `json:"user_id,omitempty" format:"uuid"`
OrgID *uuid.UUID `json:"org_id,omitempty" format:"uuid"`
SecretHash *string `json:"-"`
ExpiresAt *time.Time `json:"expires_at,omitempty" format:"date-time"`
Revoked bool `gorm:"not null;default:false" json:"revoked"`
Prefix *string `json:"prefix,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty" format:"date-time"`
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()" format:"date-time"`
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()" format:"date-time"`
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
OrgID *uuid.UUID `json:"org_id,omitempty" format:"uuid"`
Scope string `gorm:"not null;default:''" json:"scope"`
Purpose string `json:"purpose"`
ClusterID *uuid.UUID `json:"cluster_id,omitempty"`
IsEphemeral bool `json:"is_ephemeral"`
Name string `gorm:"not null;default:''" json:"name"`
KeyHash string `gorm:"uniqueIndex;not null" json:"-"`
SecretHash *string `json:"-"`
UserID *uuid.UUID `json:"user_id,omitempty" format:"uuid"`
ExpiresAt *time.Time `json:"expires_at,omitempty" format:"date-time"`
Revoked bool `gorm:"not null;default:false" json:"revoked"`
Prefix *string `json:"prefix,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty" format:"date-time"`
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()" format:"date-time"`
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()" format:"date-time"`
}

View File

@@ -1,67 +1,56 @@
;
// src/pages/ClustersPage.tsx
import { useEffect, useMemo, useState } from "react";
import { clustersApi } from "@/api/clusters";
import { dnsApi } from "@/api/dns";
import { loadBalancersApi } from "@/api/loadbalancers";
import { nodePoolsApi } from "@/api/node_pools";
import { serversApi } from "@/api/servers";
import type { DtoClusterResponse, DtoDomainResponse, DtoLoadBalancerResponse, DtoNodePoolResponse, DtoRecordSetResponse, DtoServerResponse } from "@/sdk";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, CheckCircle2, CircleSlash2, FileCode2, Globe2, Loader2, MapPin, Pencil, Plus, Search, Server, Wrench } from "lucide-react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { truncateMiddle } from "@/lib/utils";
import { Badge } from "@/components/ui/badge.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog.tsx";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Label } from "@/components/ui/label.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select.tsx";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table.tsx";
import { Textarea } from "@/components/ui/textarea.tsx";
;
import { useEffect, useMemo, useState } from "react"
import { clustersApi } from "@/api/clusters"
import { dnsApi } from "@/api/dns"
import { loadBalancersApi } from "@/api/loadbalancers"
import { nodePoolsApi } from "@/api/node_pools"
import { serversApi } from "@/api/servers"
import type {
DtoClusterResponse,
DtoDomainResponse,
DtoLoadBalancerResponse,
DtoNodePoolResponse,
DtoRecordSetResponse,
DtoServerResponse,
} from "@/sdk"
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import {
AlertCircle,
CheckCircle2,
CircleSlash2,
FileCode2,
Globe2,
Loader2,
MapPin,
Pencil,
Plus,
Search,
Server,
Wrench,
} from "lucide-react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { truncateMiddle } from "@/lib/utils"
import { Badge } from "@/components/ui/badge.tsx"
import { Button } from "@/components/ui/button.tsx"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog.tsx"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form.tsx"
import { Input } from "@/components/ui/input.tsx"
import { Label } from "@/components/ui/label.tsx"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select.tsx"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table.tsx"
import { Textarea } from "@/components/ui/textarea.tsx"
// --- Schemas ---

View File

@@ -8,6 +8,7 @@ import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { Badge } from "@/components/ui/badge.tsx"
import { Button } from "@/components/ui/button.tsx"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card.tsx"
import {
@@ -35,10 +36,12 @@ import {
TableRow,
} from "@/components/ui/table.tsx"
// 1) No coerce; well do the conversion in onChange
const createSchema = z.object({
name: z.string(),
expires_in_hours: z.number().min(1).max(43800),
expires_in_hours: z.number().int().min(1).max(43800),
})
type CreateValues = z.infer<typeof createSchema>
export const OrgApiKeys = () => {
@@ -52,6 +55,7 @@ export const OrgApiKeys = () => {
queryFn: () => withRefresh(() => api.listOrgKeys({ id: orgId! })),
})
// 2) Form holds numbers directly
const form = useForm<CreateValues>({
resolver: zodResolver(createSchema),
defaultValues: {
@@ -71,7 +75,7 @@ export const OrgApiKeys = () => {
void qc.invalidateQueries({ queryKey: ["org:keys", orgId] })
setShowSecret({ key: resp.org_key, secret: resp.org_secret })
toast.success("Key created")
form.reset({ name: "", expires_in_hours: undefined })
form.reset({ name: "", expires_in_hours: 720 })
},
onError: (e: any) => toast.error(e?.message ?? "Failed to create key"),
})
@@ -124,7 +128,17 @@ export const OrgApiKeys = () => {
<FormItem>
<FormLabel>Expires In (hours)</FormLabel>
<FormControl>
<Input placeholder="e.g. 720" {...field} />
<Input
type="number"
placeholder="e.g. 720"
{...field}
// 3) Convert string → number (or undefined if empty)
value={field.value ?? ""}
onChange={(e) => {
const v = e.target.value
field.onChange(v === "" ? undefined : Number(v))
}}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -148,6 +162,7 @@ export const OrgApiKeys = () => {
<TableHead>Scope</TableHead>
<TableHead>Created</TableHead>
<TableHead>Expires</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-28" />
</TableRow>
</TableHeader>
@@ -160,6 +175,33 @@ export const OrgApiKeys = () => {
<TableCell>
{k.expires_at ? new Date(k.expires_at).toLocaleString() : "-"}
</TableCell>
<TableCell>
{(() => {
const isExpired = k.expires_at ? new Date(k.expires_at) <= new Date() : false
if (k.revoked) {
return (
<Badge variant="destructive" className="font-mono">
Revoked
</Badge>
)
}
if (isExpired) {
return (
<Badge variant="outline" className="font-mono">
Expired
</Badge>
)
}
return (
<Badge variant="secondary" className="font-mono">
Active
</Badge>
)
})()}
</TableCell>
<TableCell className="text-right">
<Button variant="destructive" size="sm" onClick={() => deleteMut.mutate(k.id!)}>
Delete