Add a complete authentication and authorization system to Silo with three pluggable backends (local bcrypt, LDAP/FreeIPA, OIDC/Keycloak), session management, API token support, and role-based access control. Authentication backends: - Local: bcrypt (cost 12) password verification against users table - LDAP: FreeIPA simple bind with group-to-role mapping - OIDC: Keycloak redirect flow with realm role mapping - Backends are tried in order; users upserted to DB on first login Session and token management: - PostgreSQL-backed sessions via alexedwards/scs + pgxstore - Opaque API tokens (silo_ prefix, SHA-256 hashed, shown once) - 24h session lifetime, HttpOnly/SameSite=Lax/Secure cookies Role-based access control (admin > editor > viewer): - RequireAuth middleware: Bearer token -> session -> redirect/401 - RequireRole middleware: per-route-group minimum role enforcement - CSRF protection via justinas/nosurf on web forms, API exempt - CORS locked to configured origins when auth enabled Route restructuring: - Public: /health, /ready, /login, /auth/oidc, /auth/callback - Web (auth + CSRF): /, /projects, /schemas, /settings - API read (viewer): GET /api/** - API write (editor): POST/PUT/PATCH/DELETE /api/** User context wiring: - created_by/updated_by columns on items, projects, relationships - All create/update handlers populate tracking fields from context - CSV and BOM import handlers pass authenticated username - Revision creation tracks user across all code paths Default admin account: - Configurable via auth.local.default_admin_username/password - Env var overrides: SILO_ADMIN_USERNAME, SILO_ADMIN_PASSWORD - Idempotent: created on first startup, skipped if exists CLI and FreeCAD plugin: - silo token create/list/revoke subcommands (HTTP API client) - FreeCAD SiloClient sends Bearer token on all requests - Token read from ApiToken preference or SILO_API_TOKEN env var Web UI: - Login page (Catppuccin Mocha themed, OIDC button conditional) - Settings page with account info and API token management - User display name, role badge, and logout button in header - One-time token display banner with copy-to-clipboard Database (migration 009): - users table with role, auth_source, oidc_subject, password_hash - api_tokens table with SHA-256 hash, prefix, expiry, scopes - sessions table (scs pgxstore schema) - audit_log table (schema ready for future use) - created_by/updated_by ALTER on items, relationships, projects New dependencies: scs/v2, scs/pgxstore, go-oidc/v3, go-ldap/v3, justinas/nosurf, golang.org/x/oauth2
301 lines
7.6 KiB
Go
301 lines
7.6 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// Project represents a project in the database.
|
|
type Project struct {
|
|
ID string
|
|
Code string
|
|
Name string
|
|
Description string
|
|
CreatedAt time.Time
|
|
CreatedBy *string
|
|
}
|
|
|
|
// ProjectRepository provides project database operations.
|
|
type ProjectRepository struct {
|
|
db *DB
|
|
}
|
|
|
|
// NewProjectRepository creates a new project repository.
|
|
func NewProjectRepository(db *DB) *ProjectRepository {
|
|
return &ProjectRepository{db: db}
|
|
}
|
|
|
|
// List returns all projects.
|
|
func (r *ProjectRepository) List(ctx context.Context) ([]*Project, error) {
|
|
rows, err := r.db.pool.Query(ctx, `
|
|
SELECT id, code, name, description, created_at
|
|
FROM projects
|
|
ORDER BY code
|
|
`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var projects []*Project
|
|
for rows.Next() {
|
|
p := &Project{}
|
|
var name, desc *string
|
|
if err := rows.Scan(&p.ID, &p.Code, &name, &desc, &p.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
if name != nil {
|
|
p.Name = *name
|
|
}
|
|
if desc != nil {
|
|
p.Description = *desc
|
|
}
|
|
projects = append(projects, p)
|
|
}
|
|
|
|
return projects, rows.Err()
|
|
}
|
|
|
|
// GetByCode returns a project by its code.
|
|
func (r *ProjectRepository) GetByCode(ctx context.Context, code string) (*Project, error) {
|
|
p := &Project{}
|
|
var name, desc *string
|
|
err := r.db.pool.QueryRow(ctx, `
|
|
SELECT id, code, name, description, created_at
|
|
FROM projects
|
|
WHERE code = $1
|
|
`, code).Scan(&p.ID, &p.Code, &name, &desc, &p.CreatedAt)
|
|
|
|
if err == pgx.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if name != nil {
|
|
p.Name = *name
|
|
}
|
|
if desc != nil {
|
|
p.Description = *desc
|
|
}
|
|
|
|
return p, nil
|
|
}
|
|
|
|
// GetByID returns a project by its ID.
|
|
func (r *ProjectRepository) GetByID(ctx context.Context, id string) (*Project, error) {
|
|
p := &Project{}
|
|
var name, desc *string
|
|
err := r.db.pool.QueryRow(ctx, `
|
|
SELECT id, code, name, description, created_at
|
|
FROM projects
|
|
WHERE id = $1
|
|
`, id).Scan(&p.ID, &p.Code, &name, &desc, &p.CreatedAt)
|
|
|
|
if err == pgx.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if name != nil {
|
|
p.Name = *name
|
|
}
|
|
if desc != nil {
|
|
p.Description = *desc
|
|
}
|
|
|
|
return p, nil
|
|
}
|
|
|
|
// Create inserts a new project.
|
|
func (r *ProjectRepository) Create(ctx context.Context, p *Project) error {
|
|
return r.db.pool.QueryRow(ctx, `
|
|
INSERT INTO projects (code, name, description, created_by)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id, created_at
|
|
`, p.Code, nullIfEmpty(p.Name), nullIfEmpty(p.Description), p.CreatedBy).Scan(&p.ID, &p.CreatedAt)
|
|
}
|
|
|
|
// Update updates a project's name and description.
|
|
func (r *ProjectRepository) Update(ctx context.Context, code string, name, description string) error {
|
|
_, err := r.db.pool.Exec(ctx, `
|
|
UPDATE projects
|
|
SET name = $2, description = $3
|
|
WHERE code = $1
|
|
`, code, nullIfEmpty(name), nullIfEmpty(description))
|
|
return err
|
|
}
|
|
|
|
// Delete removes a project (will cascade to item_projects).
|
|
func (r *ProjectRepository) Delete(ctx context.Context, code string) error {
|
|
_, err := r.db.pool.Exec(ctx, `DELETE FROM projects WHERE code = $1`, code)
|
|
return err
|
|
}
|
|
|
|
// AddItemToProject associates an item with a project.
|
|
func (r *ProjectRepository) AddItemToProject(ctx context.Context, itemID, projectID string) error {
|
|
_, err := r.db.pool.Exec(ctx, `
|
|
INSERT INTO item_projects (item_id, project_id)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT (item_id, project_id) DO NOTHING
|
|
`, itemID, projectID)
|
|
return err
|
|
}
|
|
|
|
// AddItemToProjectByCode associates an item with a project by code.
|
|
func (r *ProjectRepository) AddItemToProjectByCode(ctx context.Context, itemID, projectCode string) error {
|
|
_, err := r.db.pool.Exec(ctx, `
|
|
INSERT INTO item_projects (item_id, project_id)
|
|
SELECT $1, id FROM projects WHERE code = $2
|
|
ON CONFLICT (item_id, project_id) DO NOTHING
|
|
`, itemID, projectCode)
|
|
return err
|
|
}
|
|
|
|
// RemoveItemFromProject removes an item's association with a project.
|
|
func (r *ProjectRepository) RemoveItemFromProject(ctx context.Context, itemID, projectID string) error {
|
|
_, err := r.db.pool.Exec(ctx, `
|
|
DELETE FROM item_projects
|
|
WHERE item_id = $1 AND project_id = $2
|
|
`, itemID, projectID)
|
|
return err
|
|
}
|
|
|
|
// RemoveItemFromProjectByCode removes an item's association with a project by code.
|
|
func (r *ProjectRepository) RemoveItemFromProjectByCode(ctx context.Context, itemID, projectCode string) error {
|
|
_, err := r.db.pool.Exec(ctx, `
|
|
DELETE FROM item_projects
|
|
WHERE item_id = $1 AND project_id = (SELECT id FROM projects WHERE code = $2)
|
|
`, itemID, projectCode)
|
|
return err
|
|
}
|
|
|
|
// GetProjectsForItem returns all projects associated with an item.
|
|
func (r *ProjectRepository) GetProjectsForItem(ctx context.Context, itemID string) ([]*Project, error) {
|
|
rows, err := r.db.pool.Query(ctx, `
|
|
SELECT p.id, p.code, p.name, p.description, p.created_at
|
|
FROM projects p
|
|
JOIN item_projects ip ON ip.project_id = p.id
|
|
WHERE ip.item_id = $1
|
|
ORDER BY p.code
|
|
`, itemID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var projects []*Project
|
|
for rows.Next() {
|
|
p := &Project{}
|
|
var name, desc *string
|
|
if err := rows.Scan(&p.ID, &p.Code, &name, &desc, &p.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
if name != nil {
|
|
p.Name = *name
|
|
}
|
|
if desc != nil {
|
|
p.Description = *desc
|
|
}
|
|
projects = append(projects, p)
|
|
}
|
|
|
|
return projects, rows.Err()
|
|
}
|
|
|
|
// GetProjectCodesForItem returns project codes for an item (convenience method).
|
|
func (r *ProjectRepository) GetProjectCodesForItem(ctx context.Context, itemID string) ([]string, error) {
|
|
rows, err := r.db.pool.Query(ctx, `
|
|
SELECT p.code
|
|
FROM projects p
|
|
JOIN item_projects ip ON ip.project_id = p.id
|
|
WHERE ip.item_id = $1
|
|
ORDER BY p.code
|
|
`, itemID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var codes []string
|
|
for rows.Next() {
|
|
var code string
|
|
if err := rows.Scan(&code); err != nil {
|
|
return nil, err
|
|
}
|
|
codes = append(codes, code)
|
|
}
|
|
|
|
return codes, rows.Err()
|
|
}
|
|
|
|
// GetItemsForProject returns all items associated with a project.
|
|
func (r *ProjectRepository) GetItemsForProject(ctx context.Context, projectID string) ([]*Item, error) {
|
|
rows, err := r.db.pool.Query(ctx, `
|
|
SELECT i.id, i.part_number, i.schema_id, i.item_type, i.description,
|
|
i.created_at, i.updated_at, i.archived_at, i.current_revision,
|
|
i.cad_synced_at, i.cad_file_path
|
|
FROM items i
|
|
JOIN item_projects ip ON ip.item_id = i.id
|
|
WHERE ip.project_id = $1 AND i.archived_at IS NULL
|
|
ORDER BY i.part_number
|
|
`, projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var items []*Item
|
|
for rows.Next() {
|
|
item := &Item{}
|
|
if err := rows.Scan(
|
|
&item.ID, &item.PartNumber, &item.SchemaID, &item.ItemType, &item.Description,
|
|
&item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision,
|
|
&item.CADSyncedAt, &item.CADFilePath,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
|
|
return items, rows.Err()
|
|
}
|
|
|
|
// SetItemProjects replaces all project associations for an item.
|
|
func (r *ProjectRepository) SetItemProjects(ctx context.Context, itemID string, projectCodes []string) error {
|
|
return r.db.Tx(ctx, func(tx pgx.Tx) error {
|
|
// Remove existing associations
|
|
_, err := tx.Exec(ctx, `DELETE FROM item_projects WHERE item_id = $1`, itemID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Add new associations
|
|
for _, code := range projectCodes {
|
|
_, err := tx.Exec(ctx, `
|
|
INSERT INTO item_projects (item_id, project_id)
|
|
SELECT $1, id FROM projects WHERE code = $2
|
|
ON CONFLICT (item_id, project_id) DO NOTHING
|
|
`, itemID, code)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// helper function
|
|
func nullIfEmpty(s string) *string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return &s
|
|
}
|