Files
bssapp/svc/middlewares/authz_v2.go
2026-02-17 15:02:51 +03:00

997 lines
23 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package middlewares
import (
"bssapp-backend/internal/authz"
"bssapp-backend/permissions"
"bytes"
"database/sql"
"encoding/json"
"io"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
"bssapp-backend/auth"
"github.com/gorilla/mux"
)
/*
AuthzGuardV2
- module+action role permission check (mk_sys_role_permissions)
- optional scope checks (department / piyasa) via intersection:
user_allowed ∩ role_allowed
- cache with TTL
Expected:
- AuthMiddleware runs before this and sets JWT claims in context.
- claims should contain RoleID and UserID.
*/
// =====================================================
// 🔧 CONFIG / CONSTANTS
// =====================================================
const (
defaultPermTTL = 60 * time.Second
defaultScopeTTL = 30 * time.Second
maxBodyRead = 1 << 20 // 1MB
)
// =====================================================
// 🧠 CACHE
// =====================================================
type cacheItem struct {
val any
expires time.Time
}
type ttlCache struct {
mu sync.RWMutex
ttl time.Duration
m map[string]cacheItem
}
// =====================================================
// 🌍 GLOBAL SCOPE CACHE (for invalidation)
// =====================================================
var globalScopeCache *ttlCache
func newTTLCache(ttl time.Duration) *ttlCache {
return &ttlCache{
ttl: ttl,
m: make(map[string]cacheItem),
}
}
func (c *ttlCache) get(key string) (any, bool) {
now := time.Now()
c.mu.RLock()
item, ok := c.m[key]
c.mu.RUnlock()
if !ok {
return nil, false
}
if now.After(item.expires) {
// lazy delete
c.mu.Lock()
delete(c.m, key)
c.mu.Unlock()
return nil, false
}
return item.val, true
}
func (c *ttlCache) set(key string, val any) {
c.mu.Lock()
c.m[key] = cacheItem{val: val, expires: time.Now().Add(c.ttl)}
c.mu.Unlock()
}
// =====================================================
// ✅ MAIN MIDDLEWARE
// =====================================================
type AuthzV2Options struct {
// If true, scope checks are attempted when scope can be extracted.
EnableScopeChecks bool
// If true, when scope is required but cannot be extracted, deny.
// If false, when scope cannot be extracted, scope check is skipped.
StrictScope bool
// Override TTLs (optional)
PermTTL time.Duration
ScopeTTL time.Duration
// Custom extractors (optional). If nil, built-in extractors are used.
ExtractDepartmentCodes func(r *http.Request) []string
ExtractPiyasaCodes func(r *http.Request) []string
// Decide whether this request should be treated as scope-sensitive.
// If nil, built-in heuristic is used.
IsScopeSensitive func(module string, r *http.Request) bool
}
func AuthzGuardV2(pg *sql.DB, module string, action string) func(http.Handler) http.Handler {
return AuthzGuardV2WithOptions(pg, module, action, AuthzV2Options{
EnableScopeChecks: true,
StrictScope: false,
})
}
func AuthzGuardV2WithOptions(pg *sql.DB, module string, action string, opt AuthzV2Options) func(http.Handler) http.Handler {
permTTL := opt.PermTTL
if permTTL <= 0 {
permTTL = defaultPermTTL
}
scopeTTL := opt.ScopeTTL
if scopeTTL <= 0 {
scopeTTL = defaultScopeTTL
}
permCache := newTTLCache(permTTL)
if globalScopeCache == nil {
globalScopeCache = newTTLCache(scopeTTL)
}
scopeCache := globalScopeCache
// default extractors
extractDept := opt.ExtractDepartmentCodes
if extractDept == nil {
extractDept = defaultExtractDepartmentCodes
}
extractPiy := opt.ExtractPiyasaCodes
if extractPiy == nil {
extractPiy = defaultExtractPiyasaCodes
}
isScopeSensitive := opt.IsScopeSensitive
if isScopeSensitive == nil {
isScopeSensitive = defaultIsScopeSensitive
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// OPTIONS passthrough
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
claims, ok := auth.GetClaimsFromContext(r.Context())
if !ok || claims == nil {
http.Error(w, "unauthorized", 401)
return
}
userID := claims.ID
roleCode := claims.RoleCode
// ADMIN BYPASS
if claims.IsAdmin() {
next.ServeHTTP(w, r)
return
}
// resolve role_id from role_code
roleID, err := cachedRoleID(pg, permCache, roleCode)
if err != nil {
log.Println("❌ role resolve error:", err)
http.Error(w, "forbidden", http.StatusForbidden)
return
}
// --------------------------------------------------
// 🔐 PERMISSION RESOLUTION (USER > ROLE > DENY)
// --------------------------------------------------
permRepo := permissions.NewPermissionRepository(pg)
allowed := false
resolved := false // karar verildi mi?
// --------------------------------------------------
// 1⃣ USER OVERRIDE (ÖNCELİK)
// --------------------------------------------------
overrides, err := permRepo.GetUserOverrides(userID)
if err != nil {
log.Println("❌ override load error:", err)
http.Error(w, "forbidden", http.StatusForbidden)
return
}
for _, o := range overrides {
if o.Module == module &&
o.Action == action {
log.Printf(
"🔁 USER OVERRIDE → %s:%s = %v",
module,
action,
o.Allowed,
)
allowed = o.Allowed
resolved = true
break
}
}
// --------------------------------------------------
// 2⃣ ROLE + DEPARTMENT (NEW SYSTEM)
// --------------------------------------------------
if !resolved {
deptCodes := claims.DepartmentCodes
roleDeptAllowed, err := permRepo.ResolvePermissionChain(
userID,
roleID,
deptCodes,
module,
action,
)
if err != nil {
log.Println("❌ perm resolve error:", err)
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if roleDeptAllowed {
log.Printf("🆕 ROLE+DEPT → %s:%s = true", module, action)
allowed = true
resolved = true
} else {
log.Printf("🆕 ROLE+DEPT → %s:%s = false (try legacy)", module, action)
}
}
// --------------------------------------------------
// 3⃣ ROLE ONLY (LEGACY FALLBACK)
// --------------------------------------------------
if !resolved {
legacyAllowed, err := cachedRolePermission(
pg,
permCache,
roleID,
module,
action,
)
if err != nil {
log.Println("❌ legacy perm error:", err)
http.Error(w, "forbidden", http.StatusForbidden)
return
}
log.Printf("🕰️ LEGACY ROLE → %s:%s = %v", module, action, legacyAllowed)
allowed = legacyAllowed
resolved = true
}
// --------------------------------------------------
// 3⃣ FINAL DECISION
// --------------------------------------------------
if !allowed {
log.Printf(
"⛔ ACCESS DENIED user=%d %s:%s path=%s",
claims.ID,
module,
action,
r.URL.Path,
)
http.Error(w, "forbidden", http.StatusForbidden)
return
}
log.Printf(
"✅ ACCESS OK user=%d %s:%s %s",
claims.ID,
module,
action,
r.URL.Path,
)
// --------------------------------------------------
// 4⃣ OPTIONAL SCOPE CHECKS (FINAL - SECURE)
// --------------------------------------------------
if opt.EnableScopeChecks && isScopeSensitive(module, r) {
// 🔹 Requestten gelenler
reqDepts := normalizeCodes(extractDept(r))
reqPiy := normalizeCodes(extractPiy(r))
ctx := r.Context()
// 🔹 USER PIYASA (DBden)
userPiy, err := authz.GetUserPiyasaCodes(pg, int(userID))
if err != nil {
log.Println("❌ piyasa load error:", err)
http.Error(w, "forbidden", 403)
return
}
userPiy = normalizeCodes(userPiy)
// ------------------------------------------------
// ✅ PIYASA INTERSECTION
// ------------------------------------------------
var effectivePiy []string
switch {
case len(reqPiy) > 0 && len(userPiy) > 0:
effectivePiy = intersect(reqPiy, userPiy)
case len(reqPiy) > 0 && len(userPiy) == 0:
// user piyasa tanımlı değilse request'e güvenme → boş kalsın (StrictScope varsa deny)
effectivePiy = nil
case len(reqPiy) == 0 && len(userPiy) > 0:
effectivePiy = userPiy
}
if len(reqPiy) > 0 && len(effectivePiy) == 0 {
// request piyasa istiyor ama user scope karşılamıyor
http.Error(w, "forbidden", http.StatusForbidden)
return
}
// ------------------------------------------------
// ✅ CONTEXTE YAZ
// ------------------------------------------------
if len(reqDepts) > 0 {
ctx = authz.WithDeptCodes(ctx, reqDepts)
}
if len(effectivePiy) > 0 {
ctx = authz.WithPiyasaCodes(ctx, effectivePiy)
}
r = r.WithContext(ctx)
// ------------------------------------------------
// ❗ STRICT MODE
// ------------------------------------------------
if len(reqDepts) == 0 && len(effectivePiy) == 0 {
if opt.StrictScope {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
return
}
// ------------------------------------------------
// 🔍 DEPARTMENT CHECK
// ------------------------------------------------
if len(reqDepts) > 0 {
okDept, err := cachedDeptIntersectionAny(
pg,
scopeCache,
userID,
roleID,
reqDepts,
)
if err != nil {
log.Println("❌ dept scope error:", err)
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if !okDept {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
}
// ------------------------------------------------
// 🔍 PIYASA CHECK
// ------------------------------------------------
if len(effectivePiy) > 0 {
okPiy, err := cachedPiyasaIntersectionAny(
pg,
scopeCache,
userID,
roleID,
effectivePiy,
)
if err != nil {
log.Println("❌ piyasa scope error:", err)
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if !okPiy {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
}
}
// --------------------------------------------------
// ✅ ALLOW
// --------------------------------------------------
next.ServeHTTP(w, r)
})
}
}
// =====================================================
// 🔐 PERMISSION CHECK (mk_sys_role_permissions)
// =====================================================
func cachedRolePermission(pg *sql.DB, c *ttlCache, roleID int64, module, action string) (bool, error) {
key := "perm|" + itoa(roleID) + "|" + module + "|" + action
if v, ok := c.get(key); ok {
return v.(bool), nil
}
var allowed bool
err := pg.QueryRow(`
SELECT allowed
FROM mk_sys_role_permissions
WHERE role_id = $1 AND module_code = $2 AND action = $3
`, roleID, module, action).Scan(&allowed)
if err == sql.ErrNoRows {
c.set(key, false)
return false, nil
}
if err != nil {
return false, err
}
c.set(key, allowed)
return allowed, nil
}
// =====================================================
// 🧩 SCOPE INTERSECTION
// user scope ∩ role scope
// =====================================================
func cachedDeptIntersectionAny(pg *sql.DB, c *ttlCache, userID, roleID int64, deptCodes []string) (bool, error) {
// cache by exact request list (sorted would be ideal; normalizeCodes already stabilizes somewhat)
key := "deptAny|" + itoa(userID) + "|" + itoa(roleID) + "|" + strings.Join(deptCodes, ",")
if v, ok := c.get(key); ok {
return v.(bool), nil
}
// ANY match: if request wants multiple codes, allow if at least one is in intersection
// Intersection query:
// user departments: dfusr_dprt -> mk_dprt(code)
// role allowed: dfrole_dprt -> mk_dprt(id) OR directly by id
okAny := false
// We do it in a single query with ANY($3)
var dummy int
err := pg.QueryRow(`
SELECT 1
FROM dfusr_dprt ud
JOIN mk_dprt d ON d.id = ud.dprt_id
JOIN dfrole_dprt rd ON rd.dprt_id = ud.dprt_id
WHERE ud.dfusr_id = $1
AND rd.dfrole_id = $2
AND ud.is_active = true
AND rd.is_allowed = true
AND d.code = ANY($3)
LIMIT 1
`, userID, roleID, pqArray(deptCodes)).Scan(&dummy)
if err == sql.ErrNoRows {
c.set(key, false)
return false, nil
}
if err != nil {
return false, err
}
okAny = true
c.set(key, okAny)
return okAny, nil
}
func cachedPiyasaIntersectionAny(pg *sql.DB, c *ttlCache, userID, roleID int64, piyasaCodes []string) (bool, error) {
key := "piyAny|" + itoa(userID) + "|" + itoa(roleID) + "|" + strings.Join(piyasaCodes, ",")
if v, ok := c.get(key); ok {
return v.(bool), nil
}
var dummy int
err := pg.QueryRow(`
SELECT 1
FROM dfusr_piyasa up
WHERE up.dfusr_id = $1
AND up.is_allowed = true
AND up.piyasa_code = ANY($2)
LIMIT 1
`, userID, pqArray(piyasaCodes)).Scan(&dummy)
if err == sql.ErrNoRows {
c.set(key, false)
return false, nil
}
if err != nil {
return false, err
}
c.set(key, true)
return true, nil
}
// =====================================================
// 🧲 DEFAULT SCOPE DETECTION
// =====================================================
// defaultIsScopeSensitive decides whether this module likely needs dept/piyasa checks.
// You can tighten/extend later.
func defaultIsScopeSensitive(module string, r *http.Request) bool {
switch module {
case "order", "customer", "report", "finance":
return true
default:
return false
}
}
// =====================================================
// 🔎 DEFAULT EXTRACTORS
// =====================================================
// We try to extract scope from:
// - query params: dprt, dprt_code, department, department_code, piyasa, piyasa_code, market
// - headers: X-Department, X-Piyasa
// - json body fields: department / department_code / dprt_code, piyasa / piyasa_code / market_code
// (body read is safe: we re-inject the body)
func defaultExtractDepartmentCodes(r *http.Request) []string {
var out []string
// query params
for _, k := range []string{"dprt", "dprt_code", "department", "department_code"} {
out = append(out, splitCSV(r.URL.Query().Get(k))...)
}
// headers
out = append(out, splitCSV(r.Header.Get("X-Department"))...)
// JSON body (if any)
out = append(out, extractFromJSONBody(r, []string{
"department", "department_code", "dprt", "dprt_code",
})...)
return out
}
func defaultExtractPiyasaCodes(r *http.Request) []string {
var out []string
for _, k := range []string{"piyasa", "piyasa_code", "market", "market_code"} {
out = append(out, splitCSV(r.URL.Query().Get(k))...)
}
out = append(out, splitCSV(r.Header.Get("X-Piyasa"))...)
out = append(out, extractFromJSONBody(r, []string{
"piyasa", "piyasa_code", "market", "market_code", "customer_attribute",
})...)
return out
}
func extractFromJSONBody(r *http.Request, keys []string) []string {
// Only for methods that might have body
switch r.Method {
case http.MethodPost, http.MethodPut, http.MethodPatch:
default:
return nil
}
// read body (and restore)
raw, err := readBodyAndRestore(r, maxBodyRead)
if err != nil || len(raw) == 0 {
return nil
}
// try parse object
var obj map[string]any
if err := json.Unmarshal(raw, &obj); err != nil {
return nil
}
var out []string
for _, k := range keys {
if v, ok := obj[k]; ok {
switch t := v.(type) {
case string:
out = append(out, splitCSV(t)...)
case []any:
for _, it := range t {
if s, ok := it.(string); ok {
out = append(out, splitCSV(s)...)
}
}
}
}
}
return out
}
func readBodyAndRestore(r *http.Request, limit int64) ([]byte, error) {
if r.Body == nil {
return nil, nil
}
// Read with limit
raw, err := io.ReadAll(io.LimitReader(r.Body, limit))
if err != nil {
return nil, err
}
// restore
r.Body = io.NopCloser(bytes.NewBuffer(raw))
return raw, nil
}
// =====================================================
// 🧼 HELPERS
// =====================================================
func splitCSV(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func normalizeCodes(in []string) []string {
if len(in) == 0 {
return nil
}
seen := map[string]struct{}{}
out := make([]string, 0, len(in))
for _, s := range in {
s = strings.ToUpper(strings.TrimSpace(s))
if s == "" {
continue
}
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
// pqArray: minimal adapter to pass []string as Postgres array.
// If you already use lib/pq, replace this with pq.Array.
// Here we use a simple JSON array -> Postgres can cast text[] from ARRAY[]? Not directly.
// So: YOU SHOULD USE lib/pq in your project. If it's already there, change pqArray() to pq.Array(slice).
//
// For now, we implement as a driver.Value using "{A,B}" format (Postgres text[] literal).
type pgTextArray string
func (a pgTextArray) Value() (any, error) { return string(a), nil }
func pqArray(ss []string) any {
// produce "{A,B,C}" as text[] literal
// escape quotes minimally
if len(ss) == 0 {
return pgTextArray("{}")
}
var b strings.Builder
b.WriteString("{")
for i, s := range ss {
if i > 0 {
b.WriteString(",")
}
s = strings.ReplaceAll(s, `"`, `\"`)
b.WriteString(`"`)
b.WriteString(s)
b.WriteString(`"`)
}
b.WriteString("}")
return pgTextArray(b.String())
}
func itoa(n int64) string {
return strconv.FormatInt(n, 10)
}
// isolate strconv usage without importing it globally in this snippet
func strconvFormatInt(n int64) string {
// local minimal
// NOTE: in real code just import strconv and use strconv.FormatInt(n, 10)
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [32]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + (n % 10))
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
// optional: allow passing scope explicitly from handlers via context (advanced use)
type scopeKey string
// =====================================================
// 🔍 ROLE RESOLVER (code -> id) WITH CACHE
// =====================================================
func cachedRoleID(pg *sql.DB, c *ttlCache, roleCode string) (int64, error) {
key := "role|" + strings.ToLower(roleCode)
if v, ok := c.get(key); ok {
return v.(int64), nil
}
var id int64
err := pg.QueryRow(`
SELECT id
FROM dfrole
WHERE LOWER(code) = LOWER($1)
`, roleCode).Scan(&id)
if err != nil {
return 0, err
}
c.set(key, id)
return id, nil
}
// =====================================================
// 🧹 CACHE INVALIDATION (ADMIN)
// =====================================================
func ClearAuthzScopeCacheForUser(userID int64) {
// NOTE: this clears ALL scope cache.
// Simple & safe. Optimize later if needed.
if globalScopeCache != nil {
globalScopeCache.mu.Lock()
defer globalScopeCache.mu.Unlock()
for k := range globalScopeCache.m {
if strings.Contains(k, "|"+itoa(userID)+"|") {
delete(globalScopeCache.m, k)
}
}
}
}
// intersect: A ∩ B
func intersect(a, b []string) []string {
set := make(map[string]struct{}, len(a))
for _, v := range a {
set[v] = struct{}{}
}
var out []string
for _, v := range b {
if _, ok := set[v]; ok {
out = append(out, v)
}
}
return out
}
func AuthzGuardByRoute(pg *sql.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// =====================================================
// 0⃣ OPTIONS → PASS (CORS preflight)
// =====================================================
if r.Method == http.MethodOptions {
next.ServeHTTP(w, r)
return
}
// =====================================================
// 1⃣ AUTH
// =====================================================
claims, ok := auth.GetClaimsFromContext(r.Context())
if !ok || claims == nil {
log.Printf(
"AUTHZ_BY_ROUTE 401 reason=claims_missing method=%s path=%s",
r.Method,
r.URL.Path,
)
http.Error(w, "unauthorized: token missing or invalid", http.StatusUnauthorized)
return
}
// =====================================================
// 2⃣ REAL ROUTE TEMPLATE ( /api/users/{id} )
// =====================================================
route := mux.CurrentRoute(r)
if route == nil {
log.Printf("❌ AUTHZ: route not resolved: %s %s",
r.Method, r.URL.Path,
)
http.Error(w, "route not resolved", http.StatusForbidden)
return
}
pathTemplate, err := route.GetPathTemplate()
if err != nil {
log.Printf("❌ AUTHZ: path template error: %v", err)
http.Error(w, "route template error", http.StatusForbidden)
return
}
// Password change must be reachable for every authenticated user.
// This avoids permission deadlocks during forced first-password flow.
if pathTemplate == "/api/password/change" {
next.ServeHTTP(w, r)
return
}
// Self permission endpoints are required right after login
// to hydrate UI permission state for the authenticated user.
switch pathTemplate {
case "/api/permissions/routes", "/api/permissions/effective":
next.ServeHTTP(w, r)
return
}
// =====================================================
// 3⃣ ROUTE LOOKUP (path + method)
// =====================================================
var module, action string
err = pg.QueryRow(`
SELECT module_code, action
FROM mk_sys_routes
WHERE path = $1
AND method = $2
`,
pathTemplate,
r.Method,
).Scan(&module, &action)
if err != nil {
log.Printf(
"❌ AUTHZ: route not registered: %s %s",
r.Method,
pathTemplate,
)
if pathTemplate == "/api/password/change" {
http.Error(w, "password change route permission not found", http.StatusForbidden)
return
}
http.Error(w, "route permission not found", http.StatusForbidden)
return
}
// =====================================================
// 4⃣ PERMISSION RESOLVE
// =====================================================
repo := permissions.NewPermissionRepository(pg)
allowed, err := repo.ResolvePermissionChain(
int64(claims.ID),
int64(claims.RoleID),
claims.DepartmentCodes,
module,
action,
)
if err != nil {
log.Printf(
"❌ AUTHZ: resolve error user=%d %s:%s err=%v",
claims.ID,
module,
action,
err,
)
if pathTemplate == "/api/password/change" {
http.Error(w, "password change permission check failed", http.StatusForbidden)
return
}
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if !allowed {
log.Printf(
"⛔ AUTHZ: denied user=%d %s:%s",
claims.ID,
module,
action,
)
if pathTemplate == "/api/password/change" {
http.Error(w, "password change forbidden: permission denied", http.StatusForbidden)
return
}
http.Error(w, "forbidden", http.StatusForbidden)
return
}
// =====================================================
// 5⃣ PASS
// =====================================================
next.ServeHTTP(w, r)
})
}
}