Merge branch 'feat/open-api-v1' into 'development'

Feat/open api v1

See merge request mbugroup/lti-api!419
This commit is contained in:
Adnan Zahir
2026-04-14 16:54:16 +07:00
18 changed files with 19683 additions and 4 deletions
+132
View File
@@ -0,0 +1,132 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/apikeys"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(1)
}
db := database.Connect(config.DBHost, config.DBName)
service := apikeys.NewService(db)
ctx := context.Background()
switch os.Args[1] {
case "create":
fs := flag.NewFlagSet("create", flag.ExitOnError)
name := fs.String("name", "dashboard-read-api", "integration client name")
environment := fs.String("env", config.AppEnv, "environment label")
permissions := fs.String("permissions", "", "comma separated permission codes")
allArea := fs.Bool("all-area", true, "grant all areas")
areaIDs := fs.String("area-ids", "", "comma separated area ids")
allLocation := fs.Bool("all-location", true, "grant all locations")
locationIDs := fs.String("location-ids", "", "comma separated location ids")
fs.Parse(os.Args[2:])
permissionCodes := apikeys.DefaultDashboardPermissions()
if strings.TrimSpace(*permissions) != "" {
permissionCodes = splitCSV(*permissions)
}
issued, err := service.Create(ctx, apikeys.CreateInput{
Name: *name,
Environment: *environment,
PermissionCodes: permissionCodes,
AllArea: *allArea,
AreaIDs: parseUintCSV(*areaIDs),
AllLocation: *allLocation,
LocationIDs: parseUintCSV(*locationIDs),
})
if err != nil {
panic(err)
}
fmt.Printf("name: %s\n", issued.Record.Name)
fmt.Printf("environment: %s\n", issued.Record.Environment)
fmt.Printf("prefix: %s\n", issued.Record.KeyPrefix)
fmt.Printf("status: %s\n", issued.Record.Status)
fmt.Printf("api_key: %s\n", issued.Key)
case "list":
fs := flag.NewFlagSet("list", flag.ExitOnError)
environment := fs.String("env", "", "filter by environment")
fs.Parse(os.Args[2:])
records, err := service.List(ctx, *environment)
if err != nil {
panic(err)
}
for _, record := range records {
fmt.Printf("%s\t%s\t%s\t%s\tareas=%t\tlocations=%t\n",
record.Environment,
record.KeyPrefix,
record.Status,
record.Name,
record.AllArea,
record.AllLocation,
)
}
case "revoke":
fs := flag.NewFlagSet("revoke", flag.ExitOnError)
environment := fs.String("env", config.AppEnv, "environment label")
prefix := fs.String("prefix", "", "key prefix to revoke")
fs.Parse(os.Args[2:])
if err := service.Revoke(ctx, *environment, *prefix); err != nil {
panic(err)
}
fmt.Printf("revoked %s/%s\n", *environment, *prefix)
default:
usage()
os.Exit(1)
}
}
func usage() {
fmt.Println("usage:")
fmt.Println(" go run ./cmd/api-key create [flags]")
fmt.Println(" go run ./cmd/api-key list [flags]")
fmt.Println(" go run ./cmd/api-key revoke -env <environment> -prefix <prefix>")
}
func splitCSV(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
func parseUintCSV(raw string) []uint {
parts := splitCSV(raw)
if len(parts) == 0 {
return nil
}
values := make([]uint, 0, len(parts))
for _, part := range parts {
var value uint
if _, err := fmt.Sscanf(part, "%d", &value); err == nil && value > 0 {
values = append(values, value)
}
}
return values
}
+5
View File
@@ -9,12 +9,14 @@ import (
"syscall" "syscall"
"time" "time"
"gitlab.com/mbugroup/lti-api.git/internal/apikeys"
"gitlab.com/mbugroup/lti-api.git/internal/cache" "gitlab.com/mbugroup/lti-api.git/internal/cache"
"gitlab.com/mbugroup/lti-api.git/internal/config" "gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database" "gitlab.com/mbugroup/lti-api.git/internal/database"
"gitlab.com/mbugroup/lti-api.git/internal/middleware" "gitlab.com/mbugroup/lti-api.git/internal/middleware"
"gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session"
sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier" sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier"
"gitlab.com/mbugroup/lti-api.git/internal/readapi"
"gitlab.com/mbugroup/lti-api.git/internal/route" "gitlab.com/mbugroup/lti-api.git/internal/route"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
@@ -131,6 +133,7 @@ func setupDatabase() *gorm.DB {
} }
func setupRoutes(app *fiber.App, db *gorm.DB, rdb *redis.Client) { func setupRoutes(app *fiber.App, db *gorm.DB, rdb *redis.Client) {
middleware.SetAPIKeyAuthenticator(apikeys.NewService(db))
// route.Routes(app, db) // route.Routes(app, db)
// app.Use(utils.NotFoundHandler) // app.Use(utils.NotFoundHandler)
@@ -169,6 +172,8 @@ func setupRoutes(app *fiber.App, db *gorm.DB, rdb *redis.Client) {
return c.Status(status).JSON(body) return c.Status(status).JSON(body)
}) })
readAPIRoutes := app.Group("/api")
readapi.RegisterRoutes(readAPIRoutes)
route.Routes(app, db) route.Routes(app, db)
app.Use(utils.NotFoundHandler) app.Use(utils.NotFoundHandler)
} }
+74
View File
@@ -0,0 +1,74 @@
package main
import (
"fmt"
"os"
"path/filepath"
"gitlab.com/mbugroup/lti-api.git/internal/cache"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/readapi"
"gitlab.com/mbugroup/lti-api.git/internal/route"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
)
func main() {
root, err := findRepoRoot()
if err != nil {
panic(err)
}
readapi.PrimeBuildConfig()
cache.SetRedis(redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"}))
app := fiber.New(config.FiberConfig())
app.Get("/healthz", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ok", "service": "api", "version": config.Version})
})
app.Get("/readyz", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ok", "db": "up", "redis": "up"})
})
route.Routes(app, nil)
artifacts, err := readapi.BuildArtifactsFromApp(app)
if err != nil {
panic(err)
}
files := map[string][]byte{
filepath.Join(root, "docs", "openapi", "read-api.json"): artifacts.OpenAPIJSON,
filepath.Join(root, "docs", "openapi", "read-api.yaml"): artifacts.OpenAPIYAML,
filepath.Join(root, "docs", "postman", "read-api.collection.json"): artifacts.PostmanCollectionJSON,
filepath.Join(root, "docs", "postman", "read-api.environment.json"): artifacts.PostmanEnvironmentJSON,
}
for path, body := range files {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
panic(err)
}
if err := os.WriteFile(path, body, 0o644); err != nil {
panic(err)
}
fmt.Printf("wrote %s\n", path)
}
}
func findRepoRoot() (string, error) {
wd, err := os.Getwd()
if err != nil {
return "", err
}
current := wd
for {
if _, err := os.Stat(filepath.Join(current, "go.mod")); err == nil {
return current, nil
}
parent := filepath.Dir(current)
if parent == current {
return "", fmt.Errorf("go.mod not found from %s", wd)
}
current = parent
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+174
View File
@@ -0,0 +1,174 @@
{
"_postman_exported_at": "2026-04-14T00:00:00Z",
"_postman_exported_using": "Codex",
"_postman_variable_scope": "environment",
"id": "lti-read-api-local",
"name": "LTI ERP Read API.local",
"values": [
{
"enabled": true,
"key": "adjustment_id",
"value": "1"
},
{
"enabled": true,
"key": "api_key",
"value": ""
},
{
"enabled": true,
"key": "area_id",
"value": "1"
},
{
"enabled": true,
"key": "bank_id",
"value": "1"
},
{
"enabled": true,
"key": "base_url",
"value": "http://localhost:8081"
},
{
"enabled": true,
"key": "bearer_token",
"value": ""
},
{
"enabled": true,
"key": "chickin_id",
"value": "1"
},
{
"enabled": true,
"key": "customer_id",
"value": "1"
},
{
"enabled": true,
"key": "employee_id",
"value": "1"
},
{
"enabled": true,
"key": "expense_id",
"value": "1"
},
{
"enabled": true,
"key": "flock_id",
"value": "1"
},
{
"enabled": true,
"key": "id",
"value": "1"
},
{
"enabled": true,
"key": "idDailyChecklist",
"value": "1"
},
{
"enabled": true,
"key": "idProjectFlockKandang",
"value": "1"
},
{
"enabled": true,
"key": "initial_balance_id",
"value": "1"
},
{
"enabled": true,
"key": "injection_id",
"value": "1"
},
{
"enabled": true,
"key": "location_id",
"value": "1"
},
{
"enabled": true,
"key": "nonstock_id",
"value": "1"
},
{
"enabled": true,
"key": "payment_id",
"value": "1"
},
{
"enabled": true,
"key": "product_category_id",
"value": "1"
},
{
"enabled": true,
"key": "product_id",
"value": "1"
},
{
"enabled": true,
"key": "projectFlockId",
"value": "1"
},
{
"enabled": true,
"key": "project_flock_id",
"value": "1"
},
{
"enabled": true,
"key": "project_flock_kandang_id",
"value": "1"
},
{
"enabled": true,
"key": "purchase_id",
"value": "1"
},
{
"enabled": true,
"key": "recording_id",
"value": "1"
},
{
"enabled": true,
"key": "supplier_id",
"value": "1"
},
{
"enabled": true,
"key": "transaction_id",
"value": "1"
},
{
"enabled": true,
"key": "transfer_id",
"value": "1"
},
{
"enabled": true,
"key": "uniformity_id",
"value": "1"
},
{
"enabled": true,
"key": "uom_id",
"value": "1"
},
{
"enabled": true,
"key": "user_id",
"value": "1"
},
{
"enabled": true,
"key": "warehouse_id",
"value": "1"
}
]
}
+92
View File
@@ -0,0 +1,92 @@
package apikeys
func DefaultDashboardPermissions() []string {
return []string{
"lti.approval.list",
"lti.closing.list",
"lti.closing.detail",
"lti.daily_checklist.create",
"lti.daily_checklist.dashboard.list",
"lti.daily_checklist.detail",
"lti.daily_checklist.list",
"lti.daily_checklist.master_data.activity",
"lti.daily_checklist.master_data.configuration",
"lti.daily_checklist.master_data.employee",
"lti.daily_checklist.reports",
"lti.dashboard.list",
"lti.expense.detail",
"lti.expense.list",
"lti.finance.initial_balances.detail",
"lti.finance.injections.detail",
"lti.finance.payments.detail",
"lti.finance.transactions.detail",
"lti.finance.transactions.list",
"lti.inventory.detail",
"lti.inventory.list",
"lti.inventory.product_stock.detail",
"lti.inventory.product_stock.list",
"lti.inventory.product_warehouses.detail",
"lti.inventory.product_warehouses.list",
"lti.inventory.transfer.detail",
"lti.inventory.transfer.list",
"lti.marketing.delivery_order.detail",
"lti.marketing.delivery_order.list",
"lti.master.area.detail",
"lti.master.area.list",
"lti.master.banks.detail",
"lti.master.banks.list",
"lti.master.customer.detail",
"lti.master.customer.list",
"lti.master.fcr.detail",
"lti.master.fcr.list",
"lti.master.flocks.detail",
"lti.master.flocks.list",
"lti.master.kandangs.detail",
"lti.master.kandangs.list",
"lti.master.locations.detail",
"lti.master.locations.list",
"lti.master.nonstocks.detail",
"lti.master.nonstocks.list",
"lti.master.product_categories.detail",
"lti.master.product_categories.list",
"lti.master.products.detail",
"lti.master.products.list",
"lti.master.production_standards.detail",
"lti.master.production_standards.list",
"lti.master.suppliers.detail",
"lti.master.suppliers.list",
"lti.master.uoms.detail",
"lti.master.uoms.list",
"lti.master.warehouses.detail",
"lti.master.warehouses.list",
"lti.production.chickins.detail",
"lti.production.project_flock_kandangs.closing.detail",
"lti.production.project_flock_kandangs.detail",
"lti.production.project_flock_kandangs.list",
"lti.production.project_flocks.detail",
"lti.production.project_flocks.list",
"lti.production.project_flocks.lookup",
"lti.production.project_flocks.next_period",
"lti.production.recording.detail",
"lti.production.recording.list",
"lti.production.recording.next_day",
"lti.production.transfer_to_laying.create",
"lti.production.transfer_to_laying.detail",
"lti.production.transfer_to_laying.getavailableqty",
"lti.production.transfer_to_laying.list",
"lti.production.uniformity.detail",
"lti.production.uniformity.list",
"lti.purchase.detail",
"lti.purchase.list",
"lti.repport.customerpayment.list",
"lti.repport.debtsupplier.list",
"lti.repport.delivery.list",
"lti.repport.expense.list",
"lti.repport.gethppperkandang.list",
"lti.repport.production_result.list",
"lti.repport.purchasesupplier.list",
"lti.users.detail",
"lti.users.list",
"lti.daily_checklist.master_data.kandang",
}
}
+107
View File
@@ -0,0 +1,107 @@
package apikeys
import (
"context"
"errors"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
type Repository interface {
Create(ctx context.Context, record *entity.IntegrationAPIKey) error
GetByEnvironmentAndPrefix(ctx context.Context, environment, prefix string) (*entity.IntegrationAPIKey, error)
List(ctx context.Context, environment string) ([]entity.IntegrationAPIKey, error)
Revoke(ctx context.Context, environment, prefix string, revokedAt time.Time) error
TouchLastUsed(ctx context.Context, id uint, usedAt time.Time, usedFrom string) error
}
type repository struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) Repository {
return &repository{db: db}
}
func (r *repository) Create(ctx context.Context, record *entity.IntegrationAPIKey) error {
if r.db == nil {
return errors.New("database not configured")
}
return r.db.WithContext(ctx).Create(record).Error
}
func (r *repository) GetByEnvironmentAndPrefix(ctx context.Context, environment, prefix string) (*entity.IntegrationAPIKey, error) {
if r.db == nil {
return nil, errors.New("database not configured")
}
var record entity.IntegrationAPIKey
if err := r.db.WithContext(ctx).
Where("environment = ?", environment).
Where("key_prefix = ?", prefix).
First(&record).Error; err != nil {
return nil, err
}
return &record, nil
}
func (r *repository) List(ctx context.Context, environment string) ([]entity.IntegrationAPIKey, error) {
if r.db == nil {
return nil, errors.New("database not configured")
}
query := r.db.WithContext(ctx).Model(&entity.IntegrationAPIKey{})
if environment != "" {
query = query.Where("environment = ?", environment)
}
var records []entity.IntegrationAPIKey
if err := query.Order("environment ASC").Order("name ASC").Find(&records).Error; err != nil {
return nil, err
}
return records, nil
}
func (r *repository) Revoke(ctx context.Context, environment, prefix string, revokedAt time.Time) error {
if r.db == nil {
return errors.New("database not configured")
}
updates := map[string]any{
"status": entity.IntegrationAPIKeyStatusRevoked,
"revoked_at": revokedAt,
"updated_at": revokedAt,
}
result := r.db.WithContext(ctx).
Model(&entity.IntegrationAPIKey{}).
Where("environment = ?", environment).
Where("key_prefix = ?", prefix).
Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
func (r *repository) TouchLastUsed(ctx context.Context, id uint, usedAt time.Time, usedFrom string) error {
if r.db == nil {
return errors.New("database not configured")
}
return r.db.WithContext(ctx).
Model(&entity.IntegrationAPIKey{}).
Where("id = ?", id).
Updates(map[string]any{
"last_used_at": usedAt,
"last_used_from": usedFrom,
"updated_at": usedAt,
}).Error
}
+233
View File
@@ -0,0 +1,233 @@
package apikeys
import (
"context"
"crypto/rand"
"encoding/base32"
"errors"
"fmt"
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/secure"
"gorm.io/gorm"
)
var (
ErrInvalidAPIKey = errors.New("invalid api key")
ErrInactiveKey = errors.New("inactive api key")
)
type Principal struct {
ID uint
Name string
Environment string
Permissions []string
AllArea bool
AreaIDs []uint
AllLocation bool
LocationIDs []uint
}
type Authenticator interface {
Authenticate(ctx context.Context, rawKey, source string) (*Principal, error)
}
type Service interface {
Authenticator
Create(ctx context.Context, input CreateInput) (*IssuedKey, error)
List(ctx context.Context, environment string) ([]entity.IntegrationAPIKey, error)
Revoke(ctx context.Context, environment, prefix string) error
}
type CreateInput struct {
Name string
Environment string
PermissionCodes []string
AllArea bool
AreaIDs []uint
AllLocation bool
LocationIDs []uint
}
type IssuedKey struct {
Key string
Record *entity.IntegrationAPIKey
}
type service struct {
repo Repository
now func() time.Time
}
func NewService(db *gorm.DB) Service {
return &service{
repo: NewRepository(db),
now: time.Now,
}
}
func (s *service) Authenticate(ctx context.Context, rawKey, source string) (*Principal, error) {
environment, prefix, secret, err := parseRawKey(rawKey)
if err != nil {
return nil, ErrInvalidAPIKey
}
record, err := s.repo.GetByEnvironmentAndPrefix(ctx, environment, prefix)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrInvalidAPIKey
}
return nil, err
}
if !strings.EqualFold(record.Status, entity.IntegrationAPIKeyStatusActive) || record.RevokedAt != nil {
return nil, ErrInactiveKey
}
if !secure.Verify(record.KeyHash, secret) {
return nil, ErrInvalidAPIKey
}
usedAt := s.now().UTC()
if err := s.repo.TouchLastUsed(ctx, record.ID, usedAt, strings.TrimSpace(source)); err != nil {
utils.Log.WithError(err).Warn("api key: failed to update last_used fields")
}
return &Principal{
ID: record.ID,
Name: record.Name,
Environment: record.Environment,
Permissions: canonicalPermissions(record.PermissionCodes),
AllArea: record.AllArea,
AreaIDs: uniqueUint(record.AreaIDs),
AllLocation: record.AllLocation,
LocationIDs: uniqueUint(record.LocationIDs),
}, nil
}
func (s *service) Create(ctx context.Context, input CreateInput) (*IssuedKey, error) {
name := strings.TrimSpace(input.Name)
environment := strings.ToLower(strings.TrimSpace(input.Environment))
if name == "" || environment == "" {
return nil, fmt.Errorf("name and environment are required")
}
prefix, err := randomToken(10)
if err != nil {
return nil, err
}
secret, err := randomToken(24)
if err != nil {
return nil, err
}
hash, err := secure.Hash(secret, nil)
if err != nil {
return nil, err
}
record := &entity.IntegrationAPIKey{
Name: name,
Environment: environment,
Status: entity.IntegrationAPIKeyStatusActive,
KeyPrefix: prefix,
KeyHash: hash,
PermissionCodes: canonicalPermissions(input.PermissionCodes),
AllArea: input.AllArea,
AreaIDs: uniqueUint(input.AreaIDs),
AllLocation: input.AllLocation,
LocationIDs: uniqueUint(input.LocationIDs),
}
if err := s.repo.Create(ctx, record); err != nil {
return nil, err
}
return &IssuedKey{
Key: fmt.Sprintf("lti_%s_%s_%s", environment, prefix, secret),
Record: record,
}, nil
}
func (s *service) List(ctx context.Context, environment string) ([]entity.IntegrationAPIKey, error) {
return s.repo.List(ctx, strings.ToLower(strings.TrimSpace(environment)))
}
func (s *service) Revoke(ctx context.Context, environment, prefix string) error {
environment = strings.ToLower(strings.TrimSpace(environment))
prefix = strings.TrimSpace(prefix)
if environment == "" || prefix == "" {
return fmt.Errorf("environment and prefix are required")
}
return s.repo.Revoke(ctx, environment, prefix, s.now().UTC())
}
func parseRawKey(rawKey string) (environment string, prefix string, secret string, err error) {
rawKey = strings.TrimSpace(rawKey)
parts := strings.Split(rawKey, "_")
if len(parts) != 4 || parts[0] != "lti" {
return "", "", "", ErrInvalidAPIKey
}
environment = strings.ToLower(strings.TrimSpace(parts[1]))
prefix = strings.TrimSpace(parts[2])
secret = strings.TrimSpace(parts[3])
if environment == "" || prefix == "" || secret == "" {
return "", "", "", ErrInvalidAPIKey
}
return environment, prefix, secret, nil
}
func randomToken(size int) (string, error) {
buf := make([]byte, size)
if _, err := rand.Read(buf); err != nil {
return "", err
}
encoder := base32.StdEncoding.WithPadding(base32.NoPadding)
return strings.ToLower(encoder.EncodeToString(buf)), nil
}
func canonicalPermissions(perms []string) []string {
if len(perms) == 0 {
return []string{}
}
seen := make(map[string]struct{}, len(perms))
result := make([]string, 0, len(perms))
for _, perm := range perms {
perm = strings.ToLower(strings.TrimSpace(perm))
if perm == "" {
continue
}
if _, ok := seen[perm]; ok {
continue
}
seen[perm] = struct{}{}
result = append(result, perm)
}
return result
}
func uniqueUint(values []uint) []uint {
if len(values) == 0 {
return []uint{}
}
seen := make(map[uint]struct{}, len(values))
result := make([]uint, 0, len(values))
for _, value := range values {
if value == 0 {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
+4 -2
View File
@@ -23,6 +23,7 @@ type SSOClientConfig struct {
var ( var (
IsProd bool IsProd bool
AppEnv string
AppHost string AppHost string
Version string Version string
LogLevel string LogLevel string
@@ -84,7 +85,8 @@ func init() {
loadConfig() loadConfig()
// server configuration // server configuration
IsProd = viper.GetString("APP_ENV") == "prod" AppEnv = defaultString(strings.TrimSpace(viper.GetString("APP_ENV")), "development")
IsProd = AppEnv == "prod"
AppHost = viper.GetString("APP_HOST") AppHost = viper.GetString("APP_HOST")
AppPort = viper.GetInt("APP_PORT") AppPort = viper.GetInt("APP_PORT")
Version = viper.GetString("VERSION") Version = viper.GetString("VERSION")
@@ -111,7 +113,7 @@ func init() {
// Cors // Cors
CORSAllowOrigins = parseList("CORS_ALLOW_ORIGINS") CORSAllowOrigins = parseList("CORS_ALLOW_ORIGINS")
CORSAllowMethods = parseListWithDefault("CORS_ALLOW_METHODS", "GET,POST,PUT,PATCH,DELETE,OPTIONS") CORSAllowMethods = parseListWithDefault("CORS_ALLOW_METHODS", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
CORSAllowHeaders = parseListWithDefault("CORS_ALLOW_HEADERS", "Content-Type,Authorization,X-Requested-With") CORSAllowHeaders = parseListWithDefault("CORS_ALLOW_HEADERS", "Content-Type,Authorization,X-API-Key,X-Requested-With")
CORSExposeHeaders = parseList("CORS_EXPOSE_HEADERS") CORSExposeHeaders = parseList("CORS_EXPOSE_HEADERS")
CORSAllowCredentials = viper.GetBool("CORS_ALLOW_CREDENTIALS") CORSAllowCredentials = viper.GetBool("CORS_ALLOW_CREDENTIALS")
CORSMaxAge = viper.GetInt("CORS_MAX_AGE") CORSMaxAge = viper.GetInt("CORS_MAX_AGE")
@@ -0,0 +1 @@
DROP TABLE IF EXISTS integration_api_keys;
@@ -0,0 +1,23 @@
CREATE TABLE IF NOT EXISTS integration_api_keys (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
environment VARCHAR(50) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
key_prefix VARCHAR(64) NOT NULL,
key_hash TEXT NOT NULL,
permission_codes JSONB NOT NULL DEFAULT '[]'::jsonb,
all_area BOOLEAN NOT NULL DEFAULT FALSE,
area_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
all_location BOOLEAN NOT NULL DEFAULT FALSE,
location_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
last_used_at TIMESTAMPTZ NULL,
last_used_from VARCHAR(128) NULL,
revoked_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT uq_integration_api_keys_environment_prefix UNIQUE (environment, key_prefix)
);
CREATE INDEX idx_integration_api_keys_status ON integration_api_keys (status);
CREATE INDEX idx_integration_api_keys_deleted_at ON integration_api_keys (deleted_at);
+36
View File
@@ -0,0 +1,36 @@
package entities
import (
"time"
"gorm.io/gorm"
)
const (
IntegrationAPIKeyStatusActive = "active"
IntegrationAPIKeyStatusRevoked = "revoked"
)
type IntegrationAPIKey struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"type:varchar(100);not null"`
Environment string `gorm:"type:varchar(50);not null;uniqueIndex:idx_integration_api_keys_env_prefix,priority:1"`
Status string `gorm:"type:varchar(20);not null;default:active;index"`
KeyPrefix string `gorm:"type:varchar(64);not null;uniqueIndex:idx_integration_api_keys_env_prefix,priority:2"`
KeyHash string `gorm:"type:text;not null"`
PermissionCodes []string `gorm:"type:jsonb;serializer:json;not null"`
AllArea bool `gorm:"not null;default:false"`
AreaIDs []uint `gorm:"type:jsonb;serializer:json;not null"`
AllLocation bool `gorm:"not null;default:false"`
LocationIDs []uint `gorm:"type:jsonb;serializer:json;not null"`
LastUsedAt *time.Time
LastUsedFrom string `gorm:"type:varchar(128)"`
RevokedAt *time.Time
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
func (IntegrationAPIKey) TableName() string {
return "integration_api_keys"
}
+86 -2
View File
@@ -1,9 +1,13 @@
package middleware package middleware
import ( import (
"context"
"errors"
"strings" "strings"
"sync"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"gitlab.com/mbugroup/lti-api.git/internal/apikeys"
"gitlab.com/mbugroup/lti-api.git/internal/config" "gitlab.com/mbugroup/lti-api.git/internal/config"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session"
@@ -17,11 +21,21 @@ const (
authUserLocalsKey = "auth.user" authUserLocalsKey = "auth.user"
) )
var (
verifyAccessTokenFunc = sso.VerifyAccessToken
fetchProfileFunc = sso.FetchProfile
apiKeyAuthMu sync.RWMutex
apiKeyAuthenticator apikeys.Authenticator
)
// AuthContext keeps authentication details captured by the middleware. // AuthContext keeps authentication details captured by the middleware.
type AuthContext struct { type AuthContext struct {
Token string Token string
Verification *sso.VerificationResult Verification *sso.VerificationResult
User *entity.User User *entity.User
PrincipalType string
PrincipalName string
Roles []sso.Role Roles []sso.Role
Permissions map[string]struct{} Permissions map[string]struct{}
UserAreaIDs []uint UserAreaIDs []uint
@@ -30,6 +44,13 @@ type AuthContext struct {
UserAllLocation bool UserAllLocation bool
} }
func SetAPIKeyAuthenticator(authenticator apikeys.Authenticator) {
apiKeyAuthMu.Lock()
defer apiKeyAuthMu.Unlock()
apiKeyAuthenticator = authenticator
}
// Auth validates the incoming request against the central SSO access token and // Auth validates the incoming request against the central SSO access token and
// loads the corresponding local user. Optional scopes can be provided to enforce // loads the corresponding local user. Optional scopes can be provided to enforce
// fine-grained authorization using the SSO access token scopes. // fine-grained authorization using the SSO access token scopes.
@@ -62,10 +83,20 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
} }
} }
if token == "" { if token == "" {
if c.Method() == fiber.MethodGet {
if err := authenticateAPIKey(c); err == nil {
if len(requiredScopes) > 0 {
return fiber.NewError(fiber.StatusForbidden, "Insufficient scope")
}
return c.Next()
} else if err != nil && !errors.Is(err, apikeys.ErrInvalidAPIKey) && !errors.Is(err, apikeys.ErrInactiveKey) {
return err
}
}
return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
} }
verification, err := sso.VerifyAccessToken(token) verification, err := verifyAccessTokenFunc(token)
if err != nil { if err != nil {
if sso.IsSignatureError(err) { if sso.IsSignatureError(err) {
logSignatureError("auth", tokenSource, token, err) logSignatureError("auth", tokenSource, token, err)
@@ -99,7 +130,7 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
permissions := make(map[string]struct{}) permissions := make(map[string]struct{})
var profile *sso.UserProfile var profile *sso.UserProfile
if verification.UserID != 0 { if verification.UserID != 0 {
if p, err := sso.FetchProfile(c.Context(), token, verification); err != nil { if p, err := fetchProfileFunc(c.Context(), token, verification); err != nil {
utils.Log.WithError(err).Warn("auth: failed to fetch sso profile") utils.Log.WithError(err).Warn("auth: failed to fetch sso profile")
} else { } else {
profile = p profile = p
@@ -118,6 +149,8 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
Token: token, Token: token,
Verification: verification, Verification: verification,
User: user, User: user,
PrincipalType: "user",
PrincipalName: user.Name,
Roles: roles, Roles: roles,
Permissions: permissions, Permissions: permissions,
UserAreaIDs: nil, UserAreaIDs: nil,
@@ -219,6 +252,57 @@ func bearerToken(c *fiber.Ctx) string {
return "" return ""
} }
func authenticateAPIKey(c *fiber.Ctx) error {
rawKey := strings.TrimSpace(c.Get("X-API-Key"))
if rawKey == "" {
return apikeys.ErrInvalidAPIKey
}
authenticator := currentAPIKeyAuthenticator()
if authenticator == nil {
return apikeys.ErrInvalidAPIKey
}
principal, err := authenticator.Authenticate(context.Background(), rawKey, c.IP())
if err != nil {
if errors.Is(err, apikeys.ErrInvalidAPIKey) || errors.Is(err, apikeys.ErrInactiveKey) {
return apikeys.ErrInvalidAPIKey
}
utils.Log.WithError(err).Warn("auth: api key authentication failed")
return fiber.NewError(fiber.StatusInternalServerError, "Failed to authenticate request")
}
permissions := make(map[string]struct{}, len(principal.Permissions))
for _, perm := range principal.Permissions {
if canonical := canonicalPermission(perm); canonical != "" {
permissions[canonical] = struct{}{}
}
}
c.Locals(authContextLocalsKey, &AuthContext{
Token: "",
Verification: nil,
User: nil,
PrincipalType: "api_key",
PrincipalName: principal.Name,
Roles: nil,
Permissions: permissions,
UserAreaIDs: principal.AreaIDs,
UserLocationIDs: principal.LocationIDs,
UserAllArea: principal.AllArea,
UserAllLocation: principal.AllLocation,
})
c.Locals(authUserLocalsKey, nil)
return nil
}
func currentAPIKeyAuthenticator() apikeys.Authenticator {
apiKeyAuthMu.RLock()
defer apiKeyAuthMu.RUnlock()
return apiKeyAuthenticator
}
func hasAllScopes(have, required []string) bool { func hasAllScopes(have, required []string) bool {
if len(required) == 0 { if len(required) == 0 {
return true return true
+239
View File
@@ -0,0 +1,239 @@
package middleware
import (
"context"
"errors"
"net/http/httptest"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"github.com/golang-jwt/jwt/v5"
"gitlab.com/mbugroup/lti-api.git/internal/apikeys"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier"
userValidation "gitlab.com/mbugroup/lti-api.git/internal/modules/users/validations"
)
type stubUserService struct {
user *entity.User
err error
}
func (s *stubUserService) GetAll(_ *fiber.Ctx, _ *userValidation.Query) ([]entity.User, int64, error) {
return nil, 0, nil
}
func (s *stubUserService) GetOne(_ *fiber.Ctx, _ uint) (*entity.User, error) {
return s.user, s.err
}
func (s *stubUserService) CreateOne(_ *fiber.Ctx, _ *userValidation.Create) (*entity.User, error) {
return nil, nil
}
func (s *stubUserService) UpdateOne(_ *fiber.Ctx, _ *userValidation.Update, _ uint) (*entity.User, error) {
return nil, nil
}
func (s *stubUserService) DeleteOne(_ *fiber.Ctx, _ uint) error {
return nil
}
func (s *stubUserService) GetBySSOUserID(_ *fiber.Ctx, _ uint) (*entity.User, error) {
return s.user, s.err
}
type stubAPIKeyAuthenticator struct {
principal *apikeys.Principal
err error
}
func (s *stubAPIKeyAuthenticator) Authenticate(_ context.Context, _ string, _ string) (*apikeys.Principal, error) {
return s.principal, s.err
}
func TestAuthAllowsAPIKeyOnGet(t *testing.T) {
SetAPIKeyAuthenticator(&stubAPIKeyAuthenticator{
principal: &apikeys.Principal{
Name: "dashboard",
Permissions: []string{"perm.read"},
LocationIDs: []uint{3, 5},
},
})
defer SetAPIKeyAuthenticator(nil)
app := fiber.New()
app.Get("/reports", Auth(&stubUserService{}), RequirePermissions("perm.read"), func(c *fiber.Ctx) error {
scope, err := ResolveLocationScope(c, nil)
if err != nil {
return err
}
return c.JSON(fiber.Map{
"principal": c.Locals(authContextLocalsKey).(*AuthContext).PrincipalType,
"restrict": scope.Restrict,
"ids": scope.IDs,
})
})
req := httptest.NewRequest(fiber.MethodGet, "/reports", nil)
req.Header.Set("X-API-Key", "lti_dev_prefix_secret")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != fiber.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
func TestAuthRejectsAPIKeyOnPost(t *testing.T) {
SetAPIKeyAuthenticator(&stubAPIKeyAuthenticator{
principal: &apikeys.Principal{
Name: "dashboard",
Permissions: []string{"perm.write"},
},
})
defer SetAPIKeyAuthenticator(nil)
app := fiber.New()
app.Post("/reports", Auth(&stubUserService{}), RequirePermissions("perm.write"), func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK)
})
req := httptest.NewRequest(fiber.MethodPost, "/reports", nil)
req.Header.Set("X-API-Key", "lti_dev_prefix_secret")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != fiber.StatusUnauthorized {
t.Fatalf("expected 401, got %d", resp.StatusCode)
}
}
func TestAuthRejectsInvalidAPIKey(t *testing.T) {
SetAPIKeyAuthenticator(&stubAPIKeyAuthenticator{err: apikeys.ErrInvalidAPIKey})
defer SetAPIKeyAuthenticator(nil)
app := fiber.New()
app.Get("/reports", Auth(&stubUserService{}), func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK)
})
req := httptest.NewRequest(fiber.MethodGet, "/reports", nil)
req.Header.Set("X-API-Key", "lti_dev_prefix_secret")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != fiber.StatusUnauthorized {
t.Fatalf("expected 401, got %d", resp.StatusCode)
}
}
func TestAuthRejectsInactiveAPIKey(t *testing.T) {
SetAPIKeyAuthenticator(&stubAPIKeyAuthenticator{err: apikeys.ErrInactiveKey})
defer SetAPIKeyAuthenticator(nil)
app := fiber.New()
app.Get("/reports", Auth(&stubUserService{}), func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK)
})
req := httptest.NewRequest(fiber.MethodGet, "/reports", nil)
req.Header.Set("X-API-Key", "lti_dev_prefix_secret")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != fiber.StatusUnauthorized {
t.Fatalf("expected 401, got %d", resp.StatusCode)
}
}
func TestAuthRejectsMissingPermission(t *testing.T) {
SetAPIKeyAuthenticator(&stubAPIKeyAuthenticator{
principal: &apikeys.Principal{
Name: "dashboard",
Permissions: []string{"perm.other"},
},
})
defer SetAPIKeyAuthenticator(nil)
app := fiber.New()
app.Get("/reports", Auth(&stubUserService{}), RequirePermissions("perm.read"), func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK)
})
req := httptest.NewRequest(fiber.MethodGet, "/reports", nil)
req.Header.Set("X-API-Key", "lti_dev_prefix_secret")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != fiber.StatusForbidden {
t.Fatalf("expected 403, got %d", resp.StatusCode)
}
}
func TestAuthAllowsBearerOnGet(t *testing.T) {
previousVerify := verifyAccessTokenFunc
previousProfile := fetchProfileFunc
defer func() {
verifyAccessTokenFunc = previousVerify
fetchProfileFunc = previousProfile
}()
verifyAccessTokenFunc = func(_ string) (*sso.VerificationResult, error) {
return &sso.VerificationResult{
UserID: 1,
Claims: &sso.AccessTokenClaims{
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now().UTC()),
},
},
}, nil
}
fetchProfileFunc = func(_ context.Context, _ string, _ *sso.VerificationResult) (*sso.UserProfile, error) {
return &sso.UserProfile{
Permissions: []sso.Permission{{Name: "perm.read"}},
LocationIDs: []uint{7},
}, nil
}
app := fiber.New()
app.Get("/reports", Auth(&stubUserService{user: &entity.User{Id: 9, Name: "API User"}}), RequirePermissions("perm.read"), func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"principal": c.Locals(authContextLocalsKey).(*AuthContext).PrincipalType})
})
req := httptest.NewRequest(fiber.MethodGet, "/reports", nil)
req.Header.Set("Authorization", "Bearer test-token")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != fiber.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
func TestAuthReturnsServerErrorWhenAPIKeyVerifierFailsUnexpectedly(t *testing.T) {
SetAPIKeyAuthenticator(&stubAPIKeyAuthenticator{err: errors.New("boom")})
defer SetAPIKeyAuthenticator(nil)
app := fiber.New()
app.Get("/reports", Auth(&stubUserService{}), func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK)
})
req := httptest.NewRequest(fiber.MethodGet, "/reports", nil)
req.Header.Set("X-API-Key", "lti_dev_prefix_secret")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != fiber.StatusInternalServerError {
t.Fatalf("expected 500, got %d", resp.StatusCode)
}
}
File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
package readapi
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"runtime"
"testing"
"gitlab.com/mbugroup/lti-api.git/internal/cache"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/route"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
"gopkg.in/yaml.v3"
)
func TestGeneratedArtifactsAreCurrent(t *testing.T) {
PrimeBuildConfig()
cache.SetRedis(redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"}))
app := fiber.New(config.FiberConfig())
app.Get("/healthz", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ok"})
})
app.Get("/readyz", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ok"})
})
route.Routes(app, nil)
artifacts, err := BuildArtifactsFromApp(app)
if err != nil {
t.Fatalf("build artifacts: %v", err)
}
root := repoRoot(t)
assertJSONMatchesFile(t, artifacts.OpenAPIJSON, filepath.Join(root, "docs", "openapi", "read-api.json"))
assertYAMLMatchesFile(t, artifacts.OpenAPIYAML, filepath.Join(root, "docs", "openapi", "read-api.yaml"))
assertJSONMatchesFile(t, artifacts.PostmanCollectionJSON, filepath.Join(root, "docs", "postman", "read-api.collection.json"))
assertJSONMatchesFile(t, artifacts.PostmanEnvironmentJSON, filepath.Join(root, "docs", "postman", "read-api.environment.json"))
}
func assertJSONMatchesFile(t *testing.T, got []byte, path string) {
t.Helper()
want, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var gotValue any
if err := json.Unmarshal(got, &gotValue); err != nil {
t.Fatalf("unmarshal generated json: %v", err)
}
var wantValue any
if err := json.Unmarshal(want, &wantValue); err != nil {
t.Fatalf("unmarshal fixture json: %v", err)
}
if !reflect.DeepEqual(gotValue, wantValue) {
t.Fatalf("json artifact mismatch for %s", path)
}
}
func assertYAMLMatchesFile(t *testing.T, got []byte, path string) {
t.Helper()
want, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var gotValue any
if err := yaml.Unmarshal(got, &gotValue); err != nil {
t.Fatalf("unmarshal generated yaml: %v", err)
}
var wantValue any
if err := yaml.Unmarshal(want, &wantValue); err != nil {
t.Fatalf("unmarshal fixture yaml: %v", err)
}
if !reflect.DeepEqual(gotValue, wantValue) {
t.Fatalf("yaml artifact mismatch for %s", path)
}
}
func repoRoot(t *testing.T) string {
t.Helper()
_, filename, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
return filepath.Clean(filepath.Join(filepath.Dir(filename), "..", ".."))
}