feat: open API v1 and postman collection

This commit is contained in:
Adnan Zahir
2026-04-14 15:14:31 +07:00
parent fbe0634d46
commit 1ab16cfe06
18 changed files with 14668 additions and 4 deletions
+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 nil
}
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 nil
}
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
}