From 096bf36c2f54907cb0db43791f84692726765bbd Mon Sep 17 00:00:00 2001 From: Farhan Khan Date: Thu, 26 Mar 2026 23:30:07 -0400 Subject: [PATCH] raw draft of policy-manager --- Dockerfile | 31 +++++++++++++ createpolicy.go | 113 ++++++++++++++++++++++++++++++++++++++++++++++++ db.go | 38 ++++++++++++++++ go.mod | 13 ++++++ go.sum | 26 +++++++++++ listpolicy.go | 71 ++++++++++++++++++++++++++++++ main.go | 27 ++++++++++++ notes.md | 34 +++++++++++++++ 8 files changed, 353 insertions(+) create mode 100644 Dockerfile create mode 100644 createpolicy.go create mode 100644 db.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 listpolicy.go create mode 100644 main.go create mode 100644 notes.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2725acd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# ---- Build Stage ---- +FROM golang:latest AS builder + +WORKDIR /app + +# Copy go mod files first (better caching) +COPY go.mod ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build static binary +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o server + +# ---- Runtime Stage ---- +FROM gcr.io/distroless/base-debian12 + +WORKDIR /app + +# Copy only the binary from builder +COPY --from=builder /app/server . + +# Expose port +EXPOSE 8080 + +# Run as non-root user +USER nonroot:nonroot + +ENTRYPOINT ["/app/server"] + diff --git a/createpolicy.go b/createpolicy.go new file mode 100644 index 0000000..bec3d27 --- /dev/null +++ b/createpolicy.go @@ -0,0 +1,113 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "time" +) + +type CreatePolicyRequest struct { + PolicyName string `json:"policyname"` + PolicyDocument Policy `json:"policy"` +} + +func CreatePolicy(w http.ResponseWriter, r *http.Request) { + // Only allow POST method + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // XXX Temporary Account Handler + accountid := r.Header.Get("Account") + if accountid == "" { + http.Error(w, "Account header is required", http.StatusUnauthorized) + return + } + + // Optional: enforce content type + if r.Header.Get("Content-Type") != "application/json" { + http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType) + return + } + + // Read body with size limit (protect against huge requests) + const maxBodyBytes = 1 << 20 // 1 MB + r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) + + // Decode JSON into our struct + var policydocumentrequest CreatePolicyRequest + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() // strict mode - reject unknown fields + + if err := decoder.Decode(&policydocumentrequest); err != nil { + switch { + case err == io.EOF: + http.Error(w, "Empty request body", http.StatusBadRequest) + case err.Error() == "http: request body too large": + http.Error(w, "Request body too large (max 1MB)", http.StatusRequestEntityTooLarge) + default: + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + } + return + } + + // Optional: basic validation + if policydocumentrequest.PolicyDocument.Actions == nil || policydocumentrequest.PolicyDocument.Effect == "" || policydocumentrequest.PolicyDocument.Resources == nil { + http.Error(w, "Missing required fields: id and title", http.StatusBadRequest) + return + } + + // Process the results + log.Printf("New Policy: Account:%s, PolicyIdentifier=%s, Actions=%q, Effect=%q, Resources=%q, Comment=%q", + accountid, policydocumentrequest.PolicyDocument.PolicyIdentifier, policydocumentrequest.PolicyDocument.Actions, + policydocumentrequest.PolicyDocument.Effect, policydocumentrequest.PolicyDocument.Resources, policydocumentrequest.PolicyDocument.Comment) + + // Check if policy with the same name already exists for the account + checkExisting := pool.QueryRow(context.Background(), + "SELECT FROM policies WHERE accountid = $1 AND policyname = $2", + accountid, policydocumentrequest.PolicyName) + err := checkExisting.Scan() + if err == nil { + pri := "pri:iam::" + accountid + ":policy/" + policydocumentrequest.PolicyName + response := map[string]interface{}{ + "status": "fail", + "pri": pri, + "message": "Policy " + policydocumentrequest.PolicyName + " already exists: " + pri, + "timestamp": fmt.Sprintf("%d", time.Now().Unix()), + } + + json.NewEncoder(w).Encode(response) + http.Error(w, "Policy with the same name already exists for this account", http.StatusConflict) + return + } + + _, err = pool.Exec(context.Background(), + "INSERT INTO policies (accountid, policyname, document) VALUES($1, $2, $3) ON CONFLICT DO NOTHING", + accountid, policydocumentrequest.PolicyName, policydocumentrequest.PolicyDocument) + if err != nil { + log.Printf("Error inserting policy: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Success response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + + // arnservice:region:account-id:resource-type:resource-id + pri := "pri:iam::" + accountid + ":policy/" + policydocumentrequest.PolicyName + + response := map[string]interface{}{ + "status": "success", + "pri": pri, + "message": "Policy created", + "timestamp": fmt.Sprintf("%d", time.Now().Unix()), + } + + json.NewEncoder(w).Encode(response) +} diff --git a/db.go b/db.go new file mode 100644 index 0000000..fc677c2 --- /dev/null +++ b/db.go @@ -0,0 +1,38 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/jackc/pgx/v5/pgxpool" +) + +var pool *pgxpool.Pool + +func getDbPool() *pgxpool.Pool { + // Construct the connection string + // Note: Ensure your Docker Compose env vars match these keys! + dburl := fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=disable", + os.Getenv("POSTGRES_USER"), + os.Getenv("POSTGRES_PASSWORD"), + os.Getenv("POSTGRES_HOSTNAME"), + os.Getenv("POSTGRES_DB"), + ) + + var err error + // Use pgxpool.New instead of Connect for v5 + pool, err = pgxpool.New(context.Background(), dburl) + if err != nil { + log.Fatalf("Unable to create connection pool: %v", err) + } + + // Ping the database to verify the connection is actually live + err = pool.Ping(context.Background()) + if err != nil { + log.Fatalf("Unable to ping database: %v", err) + } + + return pool +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7a8fc2c --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module policy-manager + +go 1.25.0 + +require github.com/jackc/pgx/v5 v5.8.0 + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/text v0.29.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..87a6c8a --- /dev/null +++ b/go.sum @@ -0,0 +1,26 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/listpolicy.go b/listpolicy.go new file mode 100644 index 0000000..a3a1f8e --- /dev/null +++ b/listpolicy.go @@ -0,0 +1,71 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" +) + +type PolicyList struct { + Status string `json:"status"` + Policies map[string]Policy `json:"policies"` + Count int `json:"count"` + Timestamp string `json:"timestamp"` +} + +func ListPolicy(w http.ResponseWriter, r *http.Request) { + // Only allow POST method + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // XXX Temporary Account Handler + accountid := r.Header.Get("Account") + if accountid == "" { + http.Error(w, "Account header is required", http.StatusUnauthorized) + return + } + + // Optional: enforce content type + if r.Header.Get("Content-Type") != "application/json" { + http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType) + return + } + + // Read body with size limit (protect against huge requests) + const maxBodyBytes = 1 << 20 // 1 MB + r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) + + // Check if policy with the same name already exists for the account + rows, err := pool.Query(context.Background(), + "SELECT policyname, document FROM policies WHERE accountid = $1", accountid) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + defer rows.Close() + + var policylist PolicyList + policylist.Policies = make(map[string]Policy) + + for rows.Next() { + var policyname string + var document Policy + err = rows.Scan(&policyname, &document) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + policylist.Count = policylist.Count + 1 + policylist.Policies["pri:iam::"+accountid+":policy/"+policyname] = document + } + + policylist.Status = "success" + policylist.Timestamp = fmt.Sprintf("%d", time.Now().Unix()) + + json.NewEncoder(w).Encode(policylist) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..35ddf73 --- /dev/null +++ b/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "log" + "net/http" +) + +// Document represents the expected JSON structure +// You can modify this struct to match your actual data needs +type Policy struct { + PolicyIdentifier string `json:"pid"` + Comment string `json:"comment"` + Effect string `json:"effect"` + Actions []string `json:"actions"` + Resources []string `json:"resources"` +} + +func main() { + + pool = getDbPool() + log.Println("Policy Manager service started") + + http.HandleFunc("/iam/create-policy", CreatePolicy) + http.HandleFunc("/iam/list-policy", ListPolicy) + log.Println("Server running on :8080") + log.Fatal(http.ListenAndServe(":8080", nil)) +} diff --git a/notes.md b/notes.md new file mode 100644 index 0000000..0e5ede1 --- /dev/null +++ b/notes.md @@ -0,0 +1,34 @@ +## Create Policy + +``` +curl -X POST \ + -H "Content-Type: application/json" \ + -H "Account: 8715694634136" \ + -d '{ + "policyname": "MyReadOnlyPolicy", + "policy": { + "pid": "p-12345678", + "comment": "Allows read-only access to S3 buckets", + "effect": "Allow", + "actions": [ + "s3:GetObject", + "s3:ListBucket", + "s3:GetBucketLocation" + ], + "resources": [ + "arn:aws:s3:::my-company-data/*", + "arn:aws:s3:::my-company-data" + ] + } + }' \ + http://localhost:8080/iam/create-policy +``` + +## List Policies + +``` +curl -X POST \ + -H "Content-Type: application/json" \ + -H "Account: 8715694634136" \ + http://localhost:8080/iam/list-policy +``` \ No newline at end of file