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
11 changes: 10 additions & 1 deletion cmd/naplata/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/mtiluk/naplata/internal/config"
"github.com/mtiluk/naplata/internal/database"
"github.com/mtiluk/naplata/internal/permissions"
"github.com/mtiluk/naplata/internal/server"
"github.com/mtiluk/naplata/migrations"
"github.com/mtiluk/naplata/web"
Expand Down Expand Up @@ -35,9 +36,17 @@ func main() {

slog.Info("migrations applied")

store := database.NewStore(db)

if err := store.SeedPermissions(context.Background(), permissions.All); err != nil {
log.Fatal(err)
}

slog.Info("permissions seeded", "count", len(permissions.All))

sessionManager := server.NewSessionManager(cfg, db)

h := server.NewHandler(database.NewStore(db), sessionManager)
h := server.NewHandler(store, sessionManager)

mux := http.NewServeMux()

Expand Down
48 changes: 48 additions & 0 deletions internal/database/permissions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package database

import (
"context"
"fmt"

"github.com/jackc/pgx/v5"
)

func (s *Store) HasPermission(ctx context.Context, userID, permission string) (bool, error) {
var ok bool
err := s.pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1
FROM user_roles ur
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.id = rp.permission_id
WHERE ur.user_id = $1 AND p.name = $2
)`,
userID, permission,
).Scan(&ok)
if err != nil {
return false, fmt.Errorf("has permission: %w", err)
}

return ok, nil
}

func (s *Store) GetUserPermissions(ctx context.Context, userID string) ([]string, error) {
rows, _ := s.pool.Query(ctx, `
SELECT DISTINCT p.name
FROM user_roles ur
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.id = rp.permission_id
WHERE ur.user_id = $1
ORDER BY p.name`,
userID,
)
permissions, err := pgx.CollectRows(rows, pgx.RowTo[string])
if err != nil {
return nil, fmt.Errorf("get user permissions: %w", err)
}
if permissions == nil {
permissions = []string{}
}

return permissions, nil
}
67 changes: 67 additions & 0 deletions internal/database/seed.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package database

import (
"context"
"fmt"

"github.com/mtiluk/naplata/internal/permissions"
)

func (s *Store) SeedPermissions(ctx context.Context, perms []permissions.Permission) error {
names := make([]string, len(perms))
descriptions := make([]string, len(perms))
for i, p := range perms {
names[i] = p.Name
descriptions[i] = p.Description
}

tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin seed: %w", err)
}
defer tx.Rollback(ctx)

// Only insert missing rows: INSERT ... ON CONFLICT draws an id even when it conflicts.
if _, err := tx.Exec(ctx, `
INSERT INTO permissions (name, description)
SELECT t.name, t.description
FROM unnest($1::text[], $2::text[]) AS t(name, description)
WHERE NOT EXISTS (SELECT 1 FROM permissions p WHERE p.name = t.name)
ON CONFLICT (name) DO NOTHING`,
names, descriptions,
); err != nil {
return fmt.Errorf("seed permissions: %w", err)
}

if _, err := tx.Exec(ctx, `
UPDATE permissions p
SET description = t.description, updated_at = now()
FROM unnest($1::text[], $2::text[]) AS t(name, description)
WHERE p.name = t.name AND p.description IS DISTINCT FROM t.description`,
names, descriptions,
); err != nil {
return fmt.Errorf("update permission descriptions: %w", err)
}

if _, err := tx.Exec(ctx, `
INSERT INTO roles (name)
SELECT 'admin'
WHERE NOT EXISTS (SELECT 1 FROM roles WHERE name = 'admin')
ON CONFLICT (name) DO NOTHING`,
); err != nil {
return fmt.Errorf("seed admin role: %w", err)
}

if _, err := tx.Exec(ctx, `
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
CROSS JOIN permissions p
WHERE r.name = 'admin'
ON CONFLICT DO NOTHING`,
); err != nil {
return fmt.Errorf("grant admin permissions: %w", err)
}

return tx.Commit(ctx)
}
28 changes: 28 additions & 0 deletions internal/permissions/permissions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package permissions

const (
AccessPanel = "access.panel"
ClientsView = "clients.view"
ClientsManage = "clients.manage"
ProductsManage = "products.manage"
ServicesManage = "services.manage"
InvoicesManage = "invoices.manage"
PaymentsRefund = "payments.refund"
SettingsManage = "settings.manage"
)

type Permission struct {
Name string
Description string
}

var All = []Permission{
{AccessPanel, "Access the panel"},
{ClientsView, "View client accounts and their services"},
{ClientsManage, "Edit, suspend and delete client accounts"},
{ProductsManage, "Create and edit products and pricing"},
{ServicesManage, "Provision, suspend and terminate services"},
{InvoicesManage, "Create, edit and cancel invoices"},
{PaymentsRefund, "Issue refunds"},
{SettingsManage, "Change system settings"},
}
29 changes: 19 additions & 10 deletions internal/server/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ type registerRequest struct {
}

type userResponse struct {
ID string `json:"id"`
Email string `json:"email"`
GivenName string `json:"given_name"`
FamilyName string `json:"family_name"`
CreatedAt time.Time `json:"created_at"`
ID string `json:"id"`
Email string `json:"email"`
GivenName string `json:"given_name"`
FamilyName string `json:"family_name"`
CreatedAt time.Time `json:"created_at"`
Permissions []string `json:"permissions"`
}

func (h *Handler) RegisterEndpoint(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -162,11 +163,19 @@ func (h *Handler) MeEndpoint(w http.ResponseWriter, r *http.Request) {
return
}

permissions, err := h.store.GetUserPermissions(r.Context(), user.ID)
if err != nil {
slog.Error("get user permissions", "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}

writeJSON(w, http.StatusOK, userResponse{
ID: user.ID,
Email: user.Email,
GivenName: user.GivenName,
FamilyName: user.FamilyName,
CreatedAt: user.CreatedAt,
ID: user.ID,
Email: user.Email,
GivenName: user.GivenName,
FamilyName: user.FamilyName,
CreatedAt: user.CreatedAt,
Permissions: permissions,
})
}
24 changes: 24 additions & 0 deletions internal/server/middleware.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package server

import (
"log/slog"
"net/http"
)

Expand All @@ -13,3 +14,26 @@ func (h *Handler) RequireAuth(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
})
}

func (h *Handler) RequirePermission(next http.Handler, permission string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID := h.sessionManager.GetString(r.Context(), sessionUserIDKey)
if userID == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not logged in"})
return
}

hasPermission, err := h.store.HasPermission(r.Context(), userID, permission)
if err != nil {
slog.Error("check permission", "permission", permission, "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
if !hasPermission {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "permission denied"})
return
}

next.ServeHTTP(w, r)
})
}
3 changes: 3 additions & 0 deletions web/src/api/auth/me.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { queryOptions } from "@tanstack/react-query";

import type { Permission } from "@/api/auth/permissions";

export type User = {
id: string;
email: string;
given_name: string;
family_name: string;
created_at: string;
permissions: Permission[];
};

export const meQuery = queryOptions({
Expand Down
12 changes: 12 additions & 0 deletions web/src/api/auth/permissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const Permission = {
AccessPanel: "access.panel",
ClientsView: "clients.view",
ClientsManage: "clients.manage",
ProductsManage: "products.manage",
ServicesManage: "services.manage",
InvoicesManage: "invoices.manage",
PaymentsRefund: "payments.refund",
SettingsManage: "settings.manage",
} as const;

export type Permission = (typeof Permission)[keyof typeof Permission];
Loading
Loading