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:#fffLevel 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.