Principle of Least Privilege #

Bayangkan seorang kasir di sebuah toko. Ia perlu akses ke mesin kasir untuk memproses transaksi — tapi tidak perlu kunci brankas, tidak perlu akses ke ruang server, dan tidak perlu tahu kombinasi safe deposit. Memberikannya semua akses itu bukan berarti ia lebih produktif — justru sebaliknya, setiap akses ekstra adalah risiko tambahan jika akunnya dikompromikan atau jika ia melakukan kesalahan. Prinsip yang sama berlaku di software: setiap komponen, service, user, atau proses hanya boleh memiliki akses minimum yang benar-benar diperlukan untuk menjalankan fungsinya — tidak lebih. Principle of Least Privilege (PoLP) adalah fondasi dari security engineering modern. Ia bukan hanya tentang mencegah serangan eksternal — tapi juga tentang membatasi dampak ketika sesuatu salah, baik karena bug, misconfiguration, credential yang bocor, atau insider threat. Artikel ini membahas PoLP dari level yang paling konkret — database user permission, IAM policy, API token scope — hingga level arsitektur sistem dengan RBAC dan zero trust, lengkap dengan implementasi Go dan Dart yang bisa langsung diterapkan.

Mengapa Least Privilege? #

PoLP menyerang dua masalah sekaligus: blast radius dan attack surface.

Blast radius adalah seberapa besar kerusakan yang terjadi ketika sesuatu gagal atau dikompromikan. Tanpa PoLP, satu credential yang bocor bisa memberikan akses ke seluruh sistem. Dengan PoLP, credential yang sama hanya memberikan akses ke subset kecil dari sistem — kerusakan terisolasi.

Attack surface adalah jumlah titik yang bisa diserang. Setiap permission ekstra yang diberikan adalah satu titik potensial serangan tambahan. Mengurangi permission berarti mengurangi attack surface secara langsung.

Tanpa PoLP:                         Dengan PoLP:
──────────────────────────────      ──────────────────────────────────
App DB user punya:                  App DB user punya:
  SELECT, INSERT, UPDATE,             SELECT, INSERT, UPDATE, DELETE
  DELETE, DROP, CREATE TABLE,         hanya pada tabel yang dipakai
  ALTER, GRANT, TRUNCATE              (bukan DROP, CREATE, GRANT)

Jika credential bocor:              Jika credential bocor:
  Attacker bisa drop semua tabel      Attacker bisa baca/tulis data
  Bisa buat user baru                 Tidak bisa drop tabel
  Bisa exfiltrate semua data          Tidak bisa ubah schema
  Blast radius: TOTAL                 Blast radius: TERBATAS
flowchart TD
    BREACH["Credential/Token\nDikompromikan"]

    subgraph NO_POLP["Tanpa PoLP"]
        A1["Akses ke SEMUA resource"]
        A2["DROP semua tabel"]
        A3["Buat admin user baru"]
        A4["Exfiltrate semua data"]
        A5["💥 Total compromise"]
        A1 --> A2 & A3 & A4 --> A5
    end

    subgraph WITH_POLP["Dengan PoLP"]
        B1["Akses TERBATAS\nke resource yang diperlukan"]
        B2["Baca/tulis data\nyang diizinkan saja"]
        B3["⚠ Partial compromise\nBlast radius terbatas"]
        B1 --> B2 --> B3
    end

    BREACH --> NO_POLP
    BREACH --> WITH_POLP

    style A5 fill:#D9534F,color:#fff
    style B3 fill:#F0AD4E,color:#fff

Level 1 — PoLP di Database #

Database adalah tempat pertama yang paling langsung terdampak jika PoLP diabaikan. Satu kesalahan umum: menggunakan database superuser atau user dengan semua privilege untuk koneksi aplikasi.

-- ANTI-PATTERN: aplikasi menggunakan superuser atau user dengan hak berlebihan
-- Credential ini jika bocor → attacker bisa lakukan apa saja ke database

-- Jangan lakukan ini:
CREATE USER app_user WITH PASSWORD 'secret' SUPERUSER;
-- atau
GRANT ALL PRIVILEGES ON DATABASE myapp TO app_user;

-- BENAR: buat user terpisah per use case, dengan privilege minimal

-- 1. User untuk aplikasi utama (baca + tulis data)
CREATE USER app_writer WITH PASSWORD 'strong_random_password_here';
GRANT CONNECT ON DATABASE myapp TO app_writer;
GRANT USAGE ON SCHEMA public TO app_writer;
-- Hanya tabel yang dibutuhkan, hanya operasi yang dibutuhkan
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE
    users, orders, order_items, products, sessions
    TO app_writer;
-- Untuk sequence (auto increment ID)
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_writer;

-- 2. User untuk read-only (reporting, analytics, background job yang hanya baca)
CREATE USER app_reader WITH PASSWORD 'another_strong_password';
GRANT CONNECT ON DATABASE myapp TO app_reader;
GRANT USAGE ON SCHEMA public TO app_reader;
GRANT SELECT ON TABLE
    users, orders, order_items, products
    TO app_reader;
-- Tidak bisa INSERT, UPDATE, DELETE, DROP — sama sekali tidak bisa

-- 3. User untuk migration (hanya dipakai saat deploy, bukan saat runtime)
CREATE USER app_migrator WITH PASSWORD 'migration_password';
GRANT CONNECT ON DATABASE myapp TO app_migrator;
GRANT USAGE, CREATE ON SCHEMA public TO app_migrator;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO app_migrator;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO app_migrator;
-- User ini hanya dipakai oleh migration tool, tidak oleh aplikasi runtime
-- Credential-nya dirotasi setelah setiap deployment

-- 4. Pastikan privilege default untuk tabel baru juga terbatas
ALTER DEFAULT PRIVILEGES IN SCHEMA public
    GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_writer;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
    GRANT SELECT ON TABLES TO app_reader;

Implementasi di Go — menggunakan koneksi yang sesuai dengan operasi:

// Pisahkan koneksi berdasarkan privilege yang diperlukan
type Database struct {
    writer   *sql.DB // app_writer — untuk operasi tulis
    reader   *sql.DB // app_reader — untuk operasi baca
}

func NewDatabase(writerDSN, readerDSN string) (*Database, error) {
    writer, err := sql.Open("postgres", writerDSN)
    if err != nil {
        return nil, fmt.Errorf("open writer connection: %w", err)
    }

    reader, err := sql.Open("postgres", readerDSN)
    if err != nil {
        return nil, fmt.Errorf("open reader connection: %w", err)
    }

    return &Database{writer: writer, reader: reader}, nil
}

type OrderRepository struct {
    db *Database
}

// Operasi tulis → gunakan writer connection
func (r *OrderRepository) Save(ctx context.Context, order Order) error {
    _, err := r.db.writer.ExecContext(ctx,
        "INSERT INTO orders (id, user_id, total, status) VALUES ($1, $2, $3, $4)",
        order.ID, order.UserID, order.Total, order.Status,
    )
    if err != nil {
        return fmt.Errorf("save order: %w", err)
    }
    return nil
}

// Operasi baca → gunakan reader connection (least privilege)
func (r *OrderRepository) ListByUserID(ctx context.Context, userID string) ([]Order, error) {
    rows, err := r.db.reader.QueryContext(ctx,
        "SELECT id, user_id, total, status, created_at FROM orders WHERE user_id = $1",
        userID,
    )
    if err != nil {
        return nil, fmt.Errorf("list orders: %w", err)
    }
    defer rows.Close()
    // ... scan rows
    return orders, nil
}

Level 2 — PoLP di Cloud IAM #

Di cloud environment (GCP, AWS, Azure), IAM (Identity and Access Management) adalah implementasi PoLP yang paling kritikal. Service account atau role dengan permission berlebihan adalah risiko yang sering diremehkan sampai ada insiden.

ANTI-PATTERN: service account dengan permission terlalu luas

# GCP: service account dengan Editor role — punya akses ke hampir semua resource
gcloud projects add-iam-policy-binding my-project \
    --member="serviceAccount:[email protected]" \
    --role="roles/editor"
# Jika SA ini dikompromikan: attacker bisa create/delete resources,
# akses semua storage bucket, baca semua secret, dll

# AWS: IAM role dengan AdministratorAccess
{
    "Effect": "Allow",
    "Action": "*",
    "Resource": "*"
}
# Wildcard di Action dan Resource = least privilege diabaikan sepenuhnya

BENAR: permission spesifik sesuai kebutuhan aktual service

# GCP: service account untuk Order Service
# Hanya perlu: baca/tulis ke Cloud Pub/Sub topic tertentu,
#             baca secret tertentu, akses Firestore collection tertentu

# Buat custom role dengan permission minimal
gcloud iam roles create order_service_role \
    --project=my-project \
    --permissions="pubsub.topics.publish,pubsub.subscriptions.consume,\
                   secretmanager.versions.access,\
                   datastore.entities.create,datastore.entities.get,\
                   datastore.entities.update,datastore.entities.list"

# Bind ke service account spesifik untuk order service
gcloud projects add-iam-policy-binding my-project \
    --member="serviceAccount:[email protected]" \
    --role="projects/my-project/roles/order_service_role"
// Di Go, gunakan workload identity atau service account yang sudah terkonfigurasi
// Jangan hardcode credential di kode — biarkan platform inject otomatis

// ANTI-PATTERN: credential hardcoded atau di environment variable yang terlalu broad
func newStorageClient() (*storage.Client, error) {
    // JSON key file di disk = credential yang bisa dicuri, tidak pernah dirotasi otomatis
    return storage.NewClient(ctx,
        option.WithCredentialsFile("/path/to/service-account-key.json"),
    )
}

// BENAR: Application Default Credentials — platform inject identity secara otomatis
// Di GKE: Workload Identity → pod dapat identity SA secara otomatis
// Di Cloud Run: service account di-bind saat deploy
// Di VM: metadata server menyediakan token secara otomatis
func newStorageClient(ctx context.Context) (*storage.Client, error) {
    // Tidak ada credential eksplisit — gunakan ADC
    // Platform yang memastikan service ini hanya punya akses yang dikonfigurasi
    client, err := storage.NewClient(ctx)
    if err != nil {
        return nil, fmt.Errorf("create storage client: %w", err)
    }
    return client, nil
}

// Akses hanya bucket yang spesifik — bukan semua bucket
func (s *FileService) UploadOrderDocument(
    ctx context.Context,
    orderID string,
    data []byte,
) error {
    bucket := s.storage.Bucket(s.orderDocumentBucket) // bucket spesifik
    obj := bucket.Object(fmt.Sprintf("orders/%s/document.pdf", orderID))

    writer := obj.NewWriter(ctx)
    if _, err := writer.Write(data); err != nil {
        return fmt.Errorf("upload document %s: write: %w", orderID, err)
    }
    if err := writer.Close(); err != nil {
        return fmt.Errorf("upload document %s: close: %w", orderID, err)
    }
    return nil
    // Tidak ada akses ke bucket lain — PoLP di level kode
}

Level 3 — PoLP di API Token dan OAuth Scope #

API token dan OAuth scope adalah implementasi PoLP untuk akses eksternal. Token yang punya akses ke semua endpoint adalah risiko yang sama besarnya dengan password yang digunakan di mana-mana.

// ANTI-PATTERN: satu API key untuk semua operasi
// Token ini jika bocor → akses penuh ke semua fitur

const apiKey = "sk_live_everything_full_access"
// Digunakan untuk: baca user, tulis user, baca order, tulis order,
//                 baca laporan, kirim notifikasi, akses admin panel

// BENAR: token dengan scope yang terbatas sesuai kebutuhan caller

// Definisi scope yang tersedia
type Scope string

const (
    ScopeOrderRead    Scope = "order:read"
    ScopeOrderWrite   Scope = "order:write"
    ScopeUserRead     Scope = "user:read"
    ScopeUserWrite    Scope = "user:write"
    ScopeReportRead   Scope = "report:read"
    ScopeAdminAccess  Scope = "admin:*"
)

// Token store dengan scope per token
type APIToken struct {
    ID        string
    Hash      string    // bcrypt hash dari token value
    OwnerID   string
    Scopes    []Scope
    ExpiresAt time.Time
    CreatedAt time.Time
    LastUsedAt *time.Time
}

// Token yang diterbitkan sesuai kebutuhan client:
// Mobile app → [order:read, order:write, user:read]
// Analytics dashboard → [order:read, report:read]
// Admin panel → [admin:*]
// Webhook receiver → [order:read] — hanya perlu baca untuk verifikasi

// Middleware yang enforce scope
func RequireScope(required Scope) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            token, ok := tokenFromContext(r.Context())
            if !ok {
                http.Error(w, "unauthorized", http.StatusUnauthorized)
                return
            }

            if !token.HasScope(required) {
                // Log upaya akses tanpa scope yang benar — bisa jadi anomali
                slog.Warn("scope violation attempt",
                    "token_id", token.ID,
                    "required_scope", required,
                    "token_scopes", token.Scopes,
                    "path", r.URL.Path,
                )
                http.Error(w, "insufficient scope", http.StatusForbidden)
                return
            }

            next.ServeHTTP(w, r)
        })
    }
}

func (t APIToken) HasScope(required Scope) bool {
    for _, s := range t.Scopes {
        if s == required || s == ScopeAdminAccess {
            return true
        }
        // Wildcard scope matching: "order:*" mencakup "order:read" dan "order:write"
        if strings.HasSuffix(string(s), ":*") {
            prefix := strings.TrimSuffix(string(s), ":*")
            if strings.HasPrefix(string(required), prefix+":") {
                return true
            }
        }
    }
    return false
}

// Routing dengan scope enforcement
router.Get("/api/v1/orders", RequireScope(ScopeOrderRead)(listOrdersHandler))
router.Post("/api/v1/orders", RequireScope(ScopeOrderWrite)(createOrderHandler))
router.Get("/api/v1/reports/revenue", RequireScope(ScopeReportRead)(revenueReportHandler))
router.Get("/api/v1/admin/users", RequireScope(ScopeAdminAccess)(adminUserListHandler))

Level 4 — PoLP di RBAC Aplikasi #

Role-Based Access Control (RBAC) adalah implementasi PoLP di level aplikasi untuk user. Setiap user memiliki role, setiap role memiliki permission yang terdefinisi — dan tidak lebih.

// Definisi role dan permission di domain
type Role string
type Permission string

const (
    RoleCustomer Role = "customer"
    RoleMerchant Role = "merchant"
    RoleSupport  Role = "support"
    RoleAdmin    Role = "admin"
)

const (
    PermViewOwnOrders    Permission = "order:view:own"
    PermViewAllOrders    Permission = "order:view:all"
    PermCreateOrder      Permission = "order:create"
    PermCancelOwnOrder   Permission = "order:cancel:own"
    PermCancelAnyOrder   Permission = "order:cancel:any"
    PermViewProducts     Permission = "product:view"
    PermManageProducts   Permission = "product:manage"
    PermViewUsers        Permission = "user:view"
    PermManageUsers      Permission = "user:manage"
    PermViewReports      Permission = "report:view"
    PermRefundOrder      Permission = "order:refund"
)

// Permission matrix — SSOT untuk definisi akses per role
var rolePermissions = map[Role][]Permission{
    RoleCustomer: {
        PermViewOwnOrders,
        PermCreateOrder,
        PermCancelOwnOrder,
        PermViewProducts,
    },
    RoleMerchant: {
        PermViewOwnOrders,
        PermViewAllOrders,  // merchant bisa lihat semua order di toko mereka
        PermManageProducts,
        PermViewReports,
    },
    RoleSupport: {
        PermViewAllOrders,
        PermCancelAnyOrder,
        PermRefundOrder,
        PermViewUsers,
        PermViewProducts,
    },
    RoleAdmin: {
        PermViewAllOrders,
        PermCancelAnyOrder,
        PermRefundOrder,
        PermViewUsers,
        PermManageUsers,
        PermManageProducts,
        PermViewReports,
    },
    // Admin tidak dapat semua permission secara implisit —
    // ia punya permission yang terdefinisi secara eksplisit
    // Ini penting: jika ada permission baru, admin tidak otomatis mendapatkannya
    // sampai ditambahkan secara eksplisit — intentional security decision
}

type Authorizer struct {
    rolePerms map[Role][]Permission
}

func NewAuthorizer() *Authorizer {
    return &Authorizer{rolePerms: rolePermissions}
}

func (a *Authorizer) Can(role Role, perm Permission) bool {
    perms, ok := a.rolePerms[role]
    if !ok {
        return false
    }
    for _, p := range perms {
        if p == perm {
            return true
        }
    }
    return false
}

// Middleware authorization dengan resource ownership check
func (a *Authorizer) RequirePermission(perm Permission) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            user, ok := userFromContext(r.Context())
            if !ok {
                http.Error(w, "unauthorized", http.StatusUnauthorized)
                return
            }

            if !a.Can(user.Role, perm) {
                slog.Warn("authorization denied",
                    "user_id", user.ID,
                    "role", user.Role,
                    "permission", perm,
                    "path", r.URL.Path,
                )
                http.Error(w, "forbidden", http.StatusForbidden)
                return
            }

            next.ServeHTTP(w, r)
        })
    }
}

// Ownership check — customer hanya bisa akses resource miliknya
func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) {
    currentUser := userFromContext(r.Context())
    orderID := r.PathValue("id")

    order, err := h.service.GetOrder(r.Context(), orderID)
    if err != nil {
        if errors.Is(err, order.ErrNotFound) {
            http.Error(w, "order not found", http.StatusNotFound)
        } else {
            http.Error(w, "internal error", http.StatusInternalServerError)
        }
        return
    }

    // Ownership check: customer hanya boleh lihat order miliknya
    // Support dan Admin boleh lihat semua (sudah dicek di middleware)
    if currentUser.Role == RoleCustomer && order.UserID != currentUser.ID {
        // Jangan expose bahwa order ada — return 404 bukan 403
        // untuk menghindari information leakage
        http.Error(w, "order not found", http.StatusNotFound)
        return
    }

    writeSuccess(w, http.StatusOK, order)
}

// Router setup dengan RBAC
auth := NewAuthorizer()

router.Get("/api/v1/orders",
    auth.RequirePermission(PermViewAllOrders)(listAllOrdersHandler))
router.Get("/api/v1/orders/my",
    auth.RequirePermission(PermViewOwnOrders)(listMyOrdersHandler))
router.Post("/api/v1/orders",
    auth.RequirePermission(PermCreateOrder)(createOrderHandler))
router.Post("/api/v1/orders/{id}/refund",
    auth.RequirePermission(PermRefundOrder)(refundOrderHandler))
router.Get("/api/v1/admin/users",
    auth.RequirePermission(PermManageUsers)(adminUserListHandler))

Level 5 — PoLP di Secret Management #

Secret (API key, password, certificate) adalah implementasi PoLP yang sering diabaikan sampai ada kebocoran. Secret yang tersimpan di tempat yang salah, dengan akses yang terlalu luas, adalah PoLP violation yang paling sering berakhir jadi insiden keamanan.

// ANTI-PATTERN: secret di environment variable yang bisa diakses semua proses
// atau di config file yang di-commit ke repository

// ✗ Secret hardcoded
const paymentAPIKey = "sk_live_abc123xyz" // masuk ke git history selamanya

// ✗ Secret di env var tanpa enkripsi atau rotation
// DATABASE_URL=postgres://user:plaintext_password@host/db
// di-copy ke semua server tanpa audit trail

// ✗ Satu secret untuk semua environment
// Key yang sama untuk development, staging, dan production

// BENAR: secret management yang mengikuti PoLP

// Setiap service hanya bisa akses secret miliknya sendiri
// GCP Secret Manager dengan IAM binding per secret:
//
// secret: payment-api-key
//   accessor: [email protected] (roles/secretmanager.secretAccessor)
//
// secret: database-dsn
//   accessor: [email protected] (roles/secretmanager.secretAccessor)
//
// secret: smtp-password
//   accessor: [email protected]
//
// Order Service TIDAK bisa akses smtp-password — tidak ada binding IAM

type SecretManager struct {
    client    *secretmanager.Client
    projectID string
}

func (sm *SecretManager) GetSecret(ctx context.Context, name string) (string, error) {
    // Format: projects/{project}/secrets/{secret}/versions/latest
    secretName := fmt.Sprintf("projects/%s/secrets/%s/versions/latest", sm.projectID, name)

    result, err := sm.client.AccessSecretVersion(ctx,
        &secretmanagerpb.AccessSecretVersionRequest{Name: secretName},
    )
    if err != nil {
        return "", fmt.Errorf("get secret %q: %w", name, err)
    }

    return string(result.Payload.Data), nil
}

// Startup: ambil semua secret yang diperlukan satu kali
func initSecrets(ctx context.Context, sm *SecretManager) (*Secrets, error) {
    paymentKey, err := sm.GetSecret(ctx, "payment-api-key")
    if err != nil {
        return nil, fmt.Errorf("init secrets: payment key: %w", err)
    }

    dbDSN, err := sm.GetSecret(ctx, "database-dsn")
    if err != nil {
        return nil, fmt.Errorf("init secrets: database dsn: %w", err)
    }

    return &Secrets{
        PaymentAPIKey: paymentKey,
        DatabaseDSN:   dbDSN,
    }, nil
}

// Secret tidak pernah di-log atau di-expose di response
type Secrets struct {
    PaymentAPIKey string
    DatabaseDSN   string
}

// Stringer yang aman — tidak akan expose secret jika di-print secara tidak sengaja
func (s Secrets) String() string {
    return "Secrets{PaymentAPIKey: [REDACTED], DatabaseDSN: [REDACTED]}"
}

Level 6 — PoLP di Service-to-Service Communication #

Di arsitektur microservices, setiap service yang memanggil service lain harus diautentikasi dan diotorisasi. Service A tidak boleh bisa memanggil endpoint yang hanya untuk Service B — meskipun keduanya ada di network yang sama.

// ANTI-PATTERN: service communication tanpa autentikasi
// Semua service di internal network bisa panggil semua service lain
// "Kalau sudah di dalam network, pasti aman"

func callUserService(userID string) (*User, error) {
    resp, err := http.Get("http://user-service/users/" + userID)
    // Tidak ada autentikasi — siapapun di network bisa panggil ini
    if err != nil {
        return nil, err
    }
    // ...
}

// BENAR: mutual TLS atau service token untuk service-to-service auth

// Pendekatan 1: JWT service token dengan audience claim
type ServiceToken struct {
    Issuer   string   // "order-service"
    Audience string   // "user-service" — token hanya valid untuk service ini
    Scopes   []string // ["user:read"] — permission minimal yang diperlukan
    IssuedAt time.Time
    Expiry   time.Time
}

// Order Service memanggil User Service dengan token yang spesifik
type UserServiceClient struct {
    baseURL    string
    httpClient *http.Client
    tokenSrc   TokenSource // mendapat token untuk memanggil user-service
}

func (c *UserServiceClient) GetUser(ctx context.Context, userID string) (*User, error) {
    // Dapatkan token dengan audience "user-service" dan scope minimal
    token, err := c.tokenSrc.Token(ctx, TokenRequest{
        Audience: "user-service",
        Scopes:   []string{"user:read"}, // hanya read, tidak butuh write
    })
    if err != nil {
        return nil, fmt.Errorf("get service token: %w", err)
    }

    req, err := http.NewRequestWithContext(ctx,
        http.MethodGet,
        fmt.Sprintf("%s/internal/users/%s", c.baseURL, userID),
        nil,
    )
    if err != nil {
        return nil, err
    }

    req.Header.Set("Authorization", "Bearer "+token.Value)
    req.Header.Set("X-Service-Name", "order-service") // untuk audit log

    resp, err := c.httpClient.Do(req)
    if err != nil {
        return nil, fmt.Errorf("call user service: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode == http.StatusForbidden {
        return nil, ErrServiceNotAuthorized
    }
    // ...
}

// User Service memvalidasi bahwa caller adalah service yang diizinkan
func ServiceAuthMiddleware(allowedServices []string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            token := extractBearerToken(r)
            claims, err := validateServiceToken(token)
            if err != nil {
                http.Error(w, "unauthorized", http.StatusUnauthorized)
                return
            }

            // Cek apakah issuer (caller service) diizinkan
            allowed := false
            for _, svc := range allowedServices {
                if claims.Issuer == svc {
                    allowed = true
                    break
                }
            }

            if !allowed {
                slog.Warn("unauthorized service call",
                    "issuer", claims.Issuer,
                    "allowed", allowedServices,
                    "path", r.URL.Path,
                )
                http.Error(w, "forbidden", http.StatusForbidden)
                return
            }

            next.ServeHTTP(w, r)
        })
    }
}

// Internal endpoint hanya untuk service-to-service
router.Get("/internal/users/{id}",
    ServiceAuthMiddleware([]string{"order-service", "notification-service"})(
        internalGetUserHandler,
    ),
)

PoLP dan Audit Trail #

Least privilege tidak bisa ditegakkan tanpa audit. Kamu perlu tahu siapa mengakses apa, kapan, dan apakah itu sesuai dengan yang seharusnya.

// Audit middleware — setiap akses ke resource sensitif dicatat
type AuditEvent struct {
    Timestamp  time.Time `json:"timestamp"`
    ActorID    string    `json:"actor_id"`
    ActorRole  string    `json:"actor_role"`
    Action     string    `json:"action"`
    Resource   string    `json:"resource"`
    ResourceID string    `json:"resource_id"`
    Allowed    bool      `json:"allowed"`
    IPAddress  string    `json:"ip_address"`
    TraceID    string    `json:"trace_id"`
}

type AuditLogger struct {
    logger *slog.Logger
}

func (a *AuditLogger) Log(ctx context.Context, event AuditEvent) {
    event.Timestamp = time.Now()
    a.logger.InfoContext(ctx, "audit",
        "actor_id", event.ActorID,
        "actor_role", event.ActorRole,
        "action", event.Action,
        "resource", event.Resource,
        "resource_id", event.ResourceID,
        "allowed", event.Allowed,
        "ip_address", event.IPAddress,
        "trace_id", event.TraceID,
    )
}

func AuditMiddleware(audit *AuditLogger, action, resource string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            user, _ := userFromContext(r.Context())

            rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
            next.ServeHTTP(rec, r)

            audit.Log(r.Context(), AuditEvent{
                ActorID:    user.ID,
                ActorRole:  string(user.Role),
                Action:     action,
                Resource:   resource,
                ResourceID: r.PathValue("id"),
                Allowed:    rec.status != http.StatusForbidden,
                IPAddress:  r.RemoteAddr,
                TraceID:    traceIDFromContext(r.Context()),
            })
        })
    }
}

// Endpoint sensitif dengan audit
router.Post("/api/v1/orders/{id}/refund",
    auth.RequirePermission(PermRefundOrder)(
        AuditMiddleware(auditLogger, "refund", "order")(
            refundHandler,
        ),
    ),
)

PoLP di Dart/Flutter #

Di mobile app, PoLP berlaku untuk permission device yang diminta dan untuk token yang disimpan di perangkat.

// ANTI-PATTERN: minta semua permission saat app pertama kali dibuka
class App extends StatefulWidget {
    @override
    void initState() {
        super.initState();
        // Minta semua permission sekaligus — user tidak tahu untuk apa
        _requestAllPermissions();
    }

    Future<void> _requestAllPermissions() async {
        await [
            Permission.camera,
            Permission.microphone,
            Permission.location,
            Permission.contacts,
            Permission.storage,
            Permission.notification,
        ].request();
        // User langsung skeptis: "Kenapa butuh mikrofon untuk app belanja?"
    }
}

// BENAR: minta permission hanya saat benar-benar dibutuhkan,
// dengan penjelasan yang kontekstual

class OrderService {
    // Permission kamera hanya diminta saat user mau scan barcode
    Future<void> scanBarcode() async {
        final status = await Permission.camera.status;
        if (status.isDenied) {
            // Jelaskan mengapa sebelum minta
            final shouldRequest = await _showPermissionRationale(
                title: 'Akses Kamera',
                message: 'Kamera diperlukan untuk memindai barcode produk.',
            );
            if (!shouldRequest) return;

            final result = await Permission.camera.request();
            if (!result.isGranted) {
                throw PermissionDeniedException('Camera permission is required for barcode scanning');
            }
        }
        // Baru scan barcode setelah permission diberikan
        await _doScanBarcode();
    }
}

// ANTI-PATTERN: simpan token akses tanpa expiry di local storage
// SharedPreferences.setString('auth_token', fullAccessToken)
// Token tidak pernah expired, disimpan sebagai plain text

// BENAR: token dengan scope minimal, expiry yang singkat, dan penyimpanan aman
class TokenStorage {
    static const _storage = FlutterSecureStorage(
        // Enkripsi di iOS Keychain dan Android Keystore
        aOptions: AndroidOptions(encryptedSharedPreferences: true),
    );

    // Simpan access token (short-lived) dan refresh token (long-lived)
    // terpisah, dengan mekanisme refresh yang otomatis
    Future<void> saveTokens({
        required String accessToken,  // expires in 15 minutes
        required String refreshToken, // expires in 30 days
    }) async {
        await Future.wait([
            _storage.write(key: 'access_token', value: accessToken),
            _storage.write(key: 'refresh_token', value: refreshToken),
        ]);
    }

    Future<String?> getAccessToken() async {
        return _storage.read(key: 'access_token');
    }

    Future<void> clearTokens() async {
        await Future.wait([
            _storage.delete(key: 'access_token'),
            _storage.delete(key: 'refresh_token'),
        ]);
    }
}

Kapan PoLP Menjadi Berlebihan #

PoLP punya titik diminishing returns — terlalu granular justru menciptakan overhead yang tidak sebanding dengan manfaat keamanannya.

TERLALU GRANULAR (over-engineering PoLP):
  ✗ Membuat role terpisah untuk setiap kombinasi permission yang mungkin
     → 50 role dengan overlap yang rumit, lebih sulit di-audit daripada aman
  ✗ Permission per field database (SELECT hanya kolom tertentu)
     untuk data yang tidak sensitif
  ✗ Token per endpoint untuk service internal yang sudah ada mTLS
  ✗ Secret rotation setiap jam untuk secret yang tidak pernah di-expose
     ke luar dan tidak ada indikator compromise

PROPORSIONAL DENGAN RISIKO:
  Data sangat sensitif (kartu kredit, kesehatan, identitas)
  → Segranular mungkin, audit penuh, rotation ketat

  Data bisnis reguler (order, produk, ulasan)
  → Role-based yang masuk akal, audit untuk operasi kritikal

  Data publik atau low-risk
  → Authentication cukup, authorization minimal

PERTANYAAN UNTUK MENENTUKAN GRANULARITAS:
  "Jika akses ini disalahgunakan, seberapa besar dampaknya?"
  "Seberapa sering permission ini perlu diubah?"
  "Apakah audit trail yang lebih detail memberikan nilai nyata?"
PoLP yang tidak di-audit sama dengan tidak ada PoLP. Permission yang sudah dikonfigurasi dengan benar tapi tidak pernah di-review bisa drift seiring waktu — permission yang awalnya diperlukan untuk sebuah fitur bisa tetap ada setelah fitur itu dihapus. Jadwalkan review permission secara berkala: setiap quarter untuk permission kritikal, setiap tahun untuk yang reguler. Gunakan tools seperti AWS IAM Access Analyzer atau GCP Recommender untuk mendapat rekomendasi otomatis tentang permission yang tidak pernah digunakan.

Anti-Pattern dalam Satu Pandangan #

// ✗ Database user dengan semua privilege
GRANT ALL PRIVILEGES ON DATABASE myapp TO app_user;

// ✗ Service account dengan Editor/Owner role
// roles/editor → bisa akses dan modifikasi hampir semua resource

// ✗ Token tanpa scope atau expiry
token := jwt.New(jwt.SigningMethodHS256)
// Tidak ada exp claim, tidak ada scope — valid selamanya untuk segalanya

// ✗ Admin role yang implisit mendapat semua permission termasuk yang baru
// "Admin bisa segalanya" — permission baru otomatis masuk ke admin tanpa review

// ✗ Secret di environment variable yang bisa dibaca semua proses
os.Getenv("DATABASE_PASSWORD") // tersedia untuk semua kode di proses ini

// ✗ Service-to-service tanpa autentikasi
http.Get("http://user-service/users/" + id) // tidak ada auth header

// ✗ Ownership check yang terlewat — customer bisa baca order user lain
func getOrder(w http.ResponseWriter, r *http.Request) {
    orderID := r.PathValue("id")
    order, _ := repo.FindByID(orderID)
    // Tidak cek apakah order.UserID == currentUser.ID
    writeSuccess(w, 200, order) // information leakage!
}

// ✗ Permission device diminta sebelum ada konteks
// Minta kamera saat app pertama dibuka, bukan saat butuh scan

Checklist Review PoLP #

DATABASE:
  □ Aplikasi tidak menggunakan superuser atau user dengan GRANT OPTION
  □ Ada user terpisah untuk read dan write (jika memungkinkan)
  □ User migration terpisah dari user runtime aplikasi
  □ Permission per tabel — bukan wildcard ke semua tabel

CLOUD IAM:
  □ Service account tidak menggunakan role primitif (Owner, Editor, Viewer)
  □ Custom role dengan permission minimal sesuai kebutuhan aktual
  □ Workload Identity digunakan (bukan JSON key file di disk)
  □ Review permission dilakukan secara berkala (quartal)

API TOKEN DAN OAUTH:
  □ Token memiliki scope yang spesifik — bukan akses penuh
  □ Token memiliki expiry yang sesuai dengan use case
  □ Token yang tidak digunakan lagi di-revoke
  □ Refresh token lebih long-lived dari access token, tapi juga ada expiry-nya

RBAC APLIKASI:
  □ Setiap role punya daftar permission yang eksplisit dan terdokumentasikan
  □ Permission baru tidak otomatis masuk ke role manapun — harus eksplisit
  □ Ownership check ada untuk resource yang bersifat per-user
  □ Akses ditolak mengembalikan 404 bukan 403 untuk menghindari information leakage

SECRET MANAGEMENT:
  □ Secret disimpan di secret manager, bukan di env file atau config file
  □ Setiap service hanya bisa akses secret miliknya — IAM binding per secret
  □ Secret tidak pernah di-log atau di-expose di response API
  □ Rotasi secret dijadwalkan dan prosedurnya terdokumentasikan

SERVICE-TO-SERVICE:
  □ Semua service-to-service call diautentikasi (service token atau mTLS)
  □ Token service memiliki audience claim yang spesifik per target service
  □ Internal endpoint tidak bisa diakses dari public internet

AUDIT:
  □ Semua akses ke resource sensitif dicatat (actor, action, resource, result)
  □ Upaya akses yang ditolak juga dicatat untuk deteksi anomali
  □ Audit log tidak bisa dimodifikasi oleh service yang diaudit

Ringkasan #

  • PoLP membatasi blast radius dan attack surface: credential yang bocor hanya memberikan akses ke subset kecil sistem — kerusakan terisolasi, bukan total compromise.
  • Database: buat user terpisah untuk write, read, dan migration. Tidak ada wildcard privilege. User runtime tidak punya hak DROP, CREATE TABLE, atau GRANT.
  • Cloud IAM: gunakan custom role dengan permission minimal, bukan Editor atau Owner. Workload Identity lebih aman dari JSON key file. Review permission setiap quarter.
  • API Token: token dengan scope yang spesifik (order:read, bukan *:*), expiry yang sesuai use case, dan revoke ketika tidak dibutuhkan lagi.
  • RBAC Aplikasi: permission matrix yang eksplisit per role — admin tidak mendapat permission baru secara otomatis. Ownership check untuk resource per-user. Return 404 bukan 403 untuk menghindari information leakage.
  • Secret Management: setiap service hanya bisa akses secret miliknya via IAM binding. Secret tidak pernah di-log. Rotasi terjadwal.
  • Service-to-Service: setiap service yang memanggil service lain harus diautentikasi dengan service token ber-audience claim spesifik. Tidak ada “network sudah aman, tidak perlu auth”.
  • Audit Trail: akses ke resource sensitif harus dicatat — termasuk yang ditolak. Audit log adalah fondasi untuk deteksi anomali dan investigasi insiden.
  • PoLP tanpa audit = tidak efektif: permission drift seiring waktu. Jadwalkan review berkala dan gunakan tools cloud untuk identifikasi permission yang tidak pernah digunakan.
  • Proporsional dengan risiko: data kartu kredit butuh granularitas maksimal; data publik cukup authentication dasar. Jangan over-engineer PoLP untuk data yang risikonya rendah.

← Sebelumnya: CoC   Berikutnya: SPOF →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact