From 571aab64bb1793f670f01bfbe7ffcc6b1a5a7b54 Mon Sep 17 00:00:00 2001 From: Farhan Khan Date: Thu, 2 Apr 2026 01:52:41 -0400 Subject: [PATCH] initial commit --- Dockerfile | 37 ++++++++++ assume-role.go | 170 ++++++++++++++++++++++++++++++++++++++++++++++ attach-role.go | 16 +++++ create-role.go | 150 ++++++++++++++++++++++++++++++++++++++++ db.go | 38 +++++++++++ get-role-token.go | 111 ++++++++++++++++++++++++++++++ go.mod | 16 +++++ go.sum | 28 ++++++++ jwt.go | 22 ++++++ main.go | 23 +++++++ 10 files changed, 611 insertions(+) create mode 100644 Dockerfile create mode 100644 assume-role.go create mode 100644 attach-role.go create mode 100644 create-role.go create mode 100644 db.go create mode 100644 get-role-token.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 jwt.go create mode 100644 main.go diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f6ce5b4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# ---- Build Stage ---- +FROM golang:1.26 AS builder + +WORKDIR /app + +# ---- Step 1: Copy go.mod/go.sum for caching ---- +# Copy only the minimal files first for layer caching +COPY libshared/go.mod libshared/go.sum ./libshared/ +COPY role-manager/go.mod role-manager/go.sum ./role-manager/ + +# ---- Step 2: Download dependencies ---- +RUN go -C libshared mod download +RUN go -C role-manager mod download + +# ---- Step 3: Copy full source code ---- +COPY libshared ./libshared +COPY role-manager ./role-manager + +# ---- Step 4: Create Go workspace inside container ---- +RUN go work init ./role-manager ./libshared + +## Optional: verify workspace +#RUN go work list + +# ---- Step 5: Build binary ---- +WORKDIR /app/role-manager +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o server + +# ---- Runtime Stage ---- +FROM gcr.io/distroless/base-debian12 + +WORKDIR /app +COPY --from=builder /app/role-manager/server . + +EXPOSE 8080 +USER nonroot:nonroot +ENTRYPOINT ["/app/server"] \ No newline at end of file diff --git a/assume-role.go b/assume-role.go new file mode 100644 index 0000000..237601e --- /dev/null +++ b/assume-role.go @@ -0,0 +1,170 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "libshared" + "log" + "net/http" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +/* + * Returns a temporary token with the permissions of the specified role. + */ + +type AssumeRoleRequest struct { + Rolename string `json:"rolename"` +} + +type AssumeRoleResponse struct { + Token string `json:"token"` +} + +func assumeRole(w http.ResponseWriter, r *http.Request) { + + log.Println("Assume Role Request") + + var response APIResponse[string] + + // Only allow POST + if r.Method != http.MethodPost { + + response.Timestamp = time.Now().Unix() + response.Status = "error" + response.Message = "HTTP POST method not allowed" + //response.Response: "Testing" + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusMethodNotAllowed) + json.NewEncoder(w).Encode(response) + return + } + + // Get JWT from Authorization header + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + + response.Timestamp = time.Now().Unix() + response.Status = "error" + response.Message = "HTTP POST method not allowed" + //response.Response: "Testing" + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(response) + return + } + + parts := strings.SplitN(authHeader, " ", 2) + + if len(parts) != 2 || parts[0] != "Bearer" { + fmt.Println("Invalid Authorization header format") + http.Error(w, "Invalid Authorization format", http.StatusUnauthorized) + return + } + + tokenString := parts[1] + + // Replace with your actual secret or keyfunc + secret := []byte("super-secret-key") + + claims := &MyClaims{} + + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + // Validate signing method if needed + return secret, nil + }) + + if err != nil || !token.Valid { + fmt.Println("Invalid token:", err) + http.Error(w, "Invalid token", http.StatusUnauthorized) + return + } + + // Access parsed values + log.Println("sub:", claims.Sub) + log.Println("purpose:", claims.Purpose) + log.Println("account:", claims.Account) + // exp and iat come from RegisteredClaims + log.Println("exp:", claims.ExpiresAt) + log.Println("iat:", claims.IssuedAt) + + if claims.ExpiresAt == nil || time.Now().After(claims.ExpiresAt.Time) { + log.Println("Token expired") + http.Error(w, "Token expired", http.StatusUnauthorized) + return + } + + // Read the raw request body + body, err := io.ReadAll(r.Body) + + if err != nil { + log.Println("Failed to read body:", err) + http.Error(w, "Failed to read body", http.StatusBadRequest) + return + } + + defer r.Body.Close() + + // Parse JSON into Role struct + var role AssumeRoleRequest + if err := json.Unmarshal(body, &role); err != nil { + log.Println("Invalid JSON:", err) + http.Error(w, "Invalid JSON", http.StatusBadRequest) + return + } + + if role.Rolename == "" { + log.Println("Rolename is required") + http.Error(w, "Rolename is required", http.StatusBadRequest) + return + } + + // Print the parsed values to terminal + log.Println("Rolename:", role.Rolename) + + // Prevent duplication of roles with the same name for the same account + + checkExisting := pool.QueryRow(context.Background(), + "SELECT id FROM roles WHERE accountid = $1 AND rolename = $2", + claims.Account, role.Rolename) + + var existingRoleID int64 + err = checkExisting.Scan(&existingRoleID) + + if err != nil { + log.Println("Role does not exist", err) + w.WriteHeader(http.StatusNotFound) + return + } + + fmt.Println("Role ID", existingRoleID) + + roleToken, err := createJWT(secret, claims.Account, role.Rolename) + if err != nil { + log.Println("Error creating JWT:", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + fmt.Println(roleToken) + + apiresponse := libshared.APIResponse[AssumeRoleResponse]{} + apiresponse.Timestamp = time.Now().Unix() + apiresponse.Status = "success" + apiresponse.Message = "Role assumed successfully" + apiresponse.Content = AssumeRoleResponse{Token: roleToken} + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(apiresponse) + + // w.Write([]byte("JWT parsed successfully")) + +} diff --git a/attach-role.go b/attach-role.go new file mode 100644 index 0000000..ec0d1d0 --- /dev/null +++ b/attach-role.go @@ -0,0 +1,16 @@ +package main + +import ( + "log" + "net/http" +) + +func attachRole(w http.ResponseWriter, r *http.Request) { + log.Println("attachRole called") + +} + +func detachRole(w http.ResponseWriter, r *http.Request) { + log.Println("detachRole called") + +} diff --git a/create-role.go b/create-role.go new file mode 100644 index 0000000..00c5c7a --- /dev/null +++ b/create-role.go @@ -0,0 +1,150 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "log" + "net/http" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type APIResponse[T any] struct { + Timestamp int64 `json:"timestamp"` + Status string `json:"status"` // "success" or "fail" + Message string `json:"message"` + Response T `json:"response"` +} + +// Custom claims struct +type MyClaims struct { + Sub string `json:"sub"` + Purpose string `json:"purpose"` + Account string `json:"account"` + jwt.RegisteredClaims +} + +type RoleCreateType struct { + Rolename string `json:"rolename"` + Description string `json:"description"` +} + +func createRole(w http.ResponseWriter, r *http.Request) { + + var response APIResponse[string] + + // Only allow POST + if r.Method != http.MethodPost { + + response.Timestamp = time.Now().Unix() + response.Status = "error" + response.Message = "HTTP POST method not allowed" + //response.Response: "Testing" + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusMethodNotAllowed) + json.NewEncoder(w).Encode(response) + return + } + + // Get JWT from Authorization header + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + + response.Timestamp = time.Now().Unix() + response.Status = "error" + response.Message = "HTTP POST method not allowed" + //response.Response: "Testing" + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(response) + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + http.Error(w, "Invalid Authorization format", http.StatusUnauthorized) + return + } + + tokenString := parts[1] + + // Replace with your actual secret or keyfunc + secret := []byte("super-secret-key") + + claims := &MyClaims{} + + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + // Validate signing method if needed + return secret, nil + }) + + if err != nil || !token.Valid { + http.Error(w, "Invalid token", http.StatusUnauthorized) + return + } + + // Access parsed values + log.Println("sub:", claims.Sub) + log.Println("purpose:", claims.Purpose) + log.Println("account:", claims.Account) + // exp and iat come from RegisteredClaims + log.Println("exp:", claims.ExpiresAt) + log.Println("iat:", claims.IssuedAt) + + if claims.ExpiresAt == nil || time.Now().After(claims.ExpiresAt.Time) { + http.Error(w, "Token expired", http.StatusUnauthorized) + return + } + + // Read the raw request body + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + // Parse JSON into Role struct + var role RoleCreateType + if err := json.Unmarshal(body, &role); err != nil { + http.Error(w, "Invalid JSON", http.StatusBadRequest) + return + } + + if role.Rolename == "" { + http.Error(w, "Rolename is required", http.StatusBadRequest) + return + } + + // Print the parsed values to terminal + log.Println("Rolename:", role.Rolename) + log.Println("Description:", role.Description) + + // Prevent duplication of roles with the same name for the same account + checkExisting := pool.QueryRow(context.Background(), + "SELECT FROM roles WHERE accountid = $1 AND rolename = $2", + claims.Account, role.Rolename) + err = checkExisting.Scan() + if err == nil { + log.Println("Already exists") + w.WriteHeader(http.StatusConflict) + return + } + + _, err = pool.Exec( + context.Background(), + "INSERT INTO roles (accountid, rolename, description) VALUES ($1, $2, $3)", + claims.Account, role.Rolename, role.Description) + if err != nil { + log.Println("Error inserting role into database:", err) + http.Error(w, "Failed to create role", http.StatusInternalServerError) + return + } + + w.Write([]byte("JWT parsed successfully")) +} 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/get-role-token.go b/get-role-token.go new file mode 100644 index 0000000..dcb69ce --- /dev/null +++ b/get-role-token.go @@ -0,0 +1,111 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "log" + "net/http" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type getRoleTokenRequest struct { + RoleName string `json:"rolename"` +} + +func getRoleToken(w http.ResponseWriter, r *http.Request) { + log.Println("getRoleToken called") + + // Only allow POST + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Get JWT from Authorization header + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + http.Error(w, "Missing Authorization header", http.StatusUnauthorized) + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + http.Error(w, "Invalid Authorization format", http.StatusUnauthorized) + return + } + + tokenString := parts[1] + + // Replace with your actual secret or keyfunc + secret := []byte("super-secret-key") + + claims := &MyClaims{} + + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + // Validate signing method if needed + return secret, nil + }) + + if err != nil || !token.Valid { + http.Error(w, "Invalid token", http.StatusUnauthorized) + return + } + + // Access parsed values + log.Println("sub:", claims.Sub) + log.Println("purpose:", claims.Purpose) + log.Println("account:", claims.Account) + // exp and iat come from RegisteredClaims + log.Println("exp:", claims.ExpiresAt) + log.Println("iat:", claims.IssuedAt) + + if claims.ExpiresAt == nil || time.Now().After(claims.ExpiresAt.Time) { + http.Error(w, "Token expired", http.StatusUnauthorized) + return + } + + log.Println("Add in validation that the user is allowed to get this token") + + // Read the raw request body + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + // Parse JSON into Role struct + var role getRoleTokenRequest + if err := json.Unmarshal(body, &role); err != nil { + http.Error(w, "Invalid JSON", http.StatusBadRequest) + return + } + + log.Println("Received request for role:", role.RoleName) + + log.Println("Authentication would taken place here") + + // Check if policy with the same name already exists for the account + checkExisting := pool.QueryRow(context.Background(), + "SELECT FROM roles WHERE accountid = $1 AND rolename = $2", + claims.Account, role.RoleName) + log.Println("checkExisting:", role.RoleName) + err = checkExisting.Scan() + if err != nil { + w.WriteHeader(http.StatusConflict) + return + } + + usertoken, err := createJWT(secret, claims.Account, role.RoleName) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + log.Println(usertoken) + +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0a42600 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module role-manager + +go 1.25.0 + +require ( + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/jackc/pgx/v5 v5.9.1 +) + +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..ee55e9a --- /dev/null +++ b/go.sum @@ -0,0 +1,28 @@ +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/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +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.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= +github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +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/jwt.go b/jwt.go new file mode 100644 index 0000000..74dd48b --- /dev/null +++ b/jwt.go @@ -0,0 +1,22 @@ +package main + +import ( + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// CreateJWT generates a signed JWT +func createJWT(secret []byte, account string, role string) (string, error) { + claims := jwt.MapClaims{ + "sub": role, // subject (user id) + "exp": time.Now().Add(time.Hour).Unix(), // expiration + "iat": time.Now().Unix(), // issued at + "purpose": "role", + "account": account, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + return token.SignedString(secret) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..b64e0e0 --- /dev/null +++ b/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "log" + "net/http" +) + +func LoginHandler(w http.ResponseWriter, r *http.Request) { +} + +func main() { + pool = getDbPool() + + http.HandleFunc("/role/create-role", createRole) + http.HandleFunc("/role/attach-role", attachRole) + http.HandleFunc("/role/detach-role", detachRole) + http.HandleFunc("/role/assume-role", assumeRole) + http.HandleFunc("/role/get-role-token", getRoleToken) + //http.HandleFunc("/role/authorize-role", authorizeRole) + + log.Println("server running on :8080") + log.Fatal(http.ListenAndServe(":8080", nil)) +}