From 4ff5984b58fd43d6c4b46030a9754171cecb488c Mon Sep 17 00:00:00 2001 From: Amir Hasanbasic Date: Thu, 4 Sep 2025 19:21:27 +0200 Subject: [PATCH 01/10] feat(secrethub): Add compact subject to OpenID JWT claims --- .../lib/secrethub/open_id_connect/jwt.ex | 19 +++++++++++++++++++ .../secrethub/open_id_connect/jwt_claim.ex | 9 +++++++++ .../test/secrethub/internal_grpc_api_test.exs | 9 +++++++++ .../open_id_connect/jwt_claim_test.exs | 16 +++++++++++++++- 4 files changed, 52 insertions(+), 1 deletion(-) diff --git a/secrethub/lib/secrethub/open_id_connect/jwt.ex b/secrethub/lib/secrethub/open_id_connect/jwt.ex index a273e87b1..404902808 100644 --- a/secrethub/lib/secrethub/open_id_connect/jwt.ex +++ b/secrethub/lib/secrethub/open_id_connect/jwt.ex @@ -8,6 +8,8 @@ defmodule Secrethub.OpenIDConnect.JWT do "jti", # Subject of the JWT "sub", + # Compact subject format with comma-separated values only + "sub2", # Recipient for which the JWT is intended "aud", # Issuer of the JWT @@ -114,6 +116,7 @@ defmodule Secrethub.OpenIDConnect.JWT do "branch" => req.git_branch_name, "pr" => req.git_pull_request_number, "sub" => req.subject, + "sub2" => build_compact_subject(req), "iss" => "https://#{req.org_username}.#{domain}", "aud" => "https://#{req.org_username}.#{domain}", "job_type" => req.job_type, @@ -153,4 +156,20 @@ defmodule Secrethub.OpenIDConnect.JWT do Secrethub.OpenIDConnect.JWTFilter.filter_claims(claims, req.org_id, req.project_id) end + + defp build_compact_subject(req) do + [ + req.org_username, + req.project_id, + req.repository_name, + req.git_ref_type, + req.git_ref + ] + |> Enum.map(&safe_string/1) + |> Enum.join(",") + end + + defp safe_string(nil), do: "" + defp safe_string(value) when is_binary(value), do: value + defp safe_string(value), do: to_string(value) end diff --git a/secrethub/lib/secrethub/open_id_connect/jwt_claim.ex b/secrethub/lib/secrethub/open_id_connect/jwt_claim.ex index 46a745bba..0db03d3f7 100644 --- a/secrethub/lib/secrethub/open_id_connect/jwt_claim.ex +++ b/secrethub/lib/secrethub/open_id_connect/jwt_claim.ex @@ -113,6 +113,15 @@ defmodule Secrethub.OpenIDConnect.JWTClaim do is_mandatory: false, is_active: true }, + "sub2" => %__MODULE__{ + name: "sub2", + description: + "Compact subject (check sub) format with comma-separated values only (org,project_id,repo,ref_type,ref)", + is_system_claim: true, + is_aws_tag: false, + is_mandatory: false, + is_active: true + }, "org_id" => %__MODULE__{ name: "org_id", description: "Organization ID", diff --git a/secrethub/test/secrethub/internal_grpc_api_test.exs b/secrethub/test/secrethub/internal_grpc_api_test.exs index bc986926f..a1e6ca5ec 100644 --- a/secrethub/test/secrethub/internal_grpc_api_test.exs +++ b/secrethub/test/secrethub/internal_grpc_api_test.exs @@ -835,6 +835,11 @@ defmodule Secrethub.InternalGrpcApi.Test do assert Map.get(jwt.fields, "aud") == "https://testera.localhost" assert Map.get(jwt.fields, "iss") == "https://testera.localhost" assert Map.get(jwt.fields, "sub") == "project:front:pipeline:semaphore.yml" + + # Verify sub2 is present and formatted correctly (compact format with comma-separated values) + expected_sub2 = "testera,#{req.project_id},,," # org_username, project_id, empty repo, empty ref_type, empty ref + assert Map.get(jwt.fields, "sub2") == expected_sub2 + assert Map.get(jwt.fields, "prj") == req.project_name assert Map.get(jwt.fields, "org") == req.org_username refute Map.has_key?(jwt.fields, "https://aws.amazon.com/tags") @@ -908,6 +913,10 @@ defmodule Secrethub.InternalGrpcApi.Test do assert Map.get(jwt.fields, "aud") == "https://testera.localhost" assert Map.get(jwt.fields, "iss") == "https://testera.localhost" assert Map.get(jwt.fields, "sub") == "project:front:pipeline:semaphore.yml" + + # Verify sub2 is present and formatted correctly for AWS tags test + expected_sub2 = "testera,#{req.project_id},my-repo,branch," # org, project_id, repo, ref_type, empty ref + assert Map.get(jwt.fields, "sub2") == expected_sub2 end test "it returns a signed token with filtered claims in on_prem mode" do diff --git a/secrethub/test/secrethub/open_id_connect/jwt_claim_test.exs b/secrethub/test/secrethub/open_id_connect/jwt_claim_test.exs index 288e2a053..8193375aa 100644 --- a/secrethub/test/secrethub/open_id_connect/jwt_claim_test.exs +++ b/secrethub/test/secrethub/open_id_connect/jwt_claim_test.exs @@ -62,7 +62,7 @@ defmodule Secrethub.OpenIDConnect.JWTClaimTest do claims = JWTClaim.optional_claims() # Check for presence of some workflow-specific claims - workflow_claims = ~w(prj_id wf_id ppl_id job_id repo branch) + workflow_claims = ~w(prj_id wf_id ppl_id job_id repo branch sub sub2) for claim <- workflow_claims do assert Map.has_key?(claims, claim) @@ -72,6 +72,20 @@ defmodule Secrethub.OpenIDConnect.JWTClaimTest do end end + test "optional_claims includes sub2 compact subject claim" do + claims = JWTClaim.optional_claims() + + assert Map.has_key?(claims, "sub2") + sub2_claim = Map.get(claims, "sub2") + + assert sub2_claim.name == "sub2" + assert String.contains?(sub2_claim.description, "comma-separated") + refute sub2_claim.is_mandatory + assert sub2_claim.is_system_claim + refute sub2_claim.is_aws_tag + assert sub2_claim.is_active + end + test "optional_claims includes AWS tag claims" do claims = JWTClaim.optional_claims() From dc8de35c5d48caee4db54cf28e8ee874d3bbefef Mon Sep 17 00:00:00 2001 From: Amir Hasanbasic Date: Tue, 9 Sep 2025 14:35:03 +0200 Subject: [PATCH 02/10] fix(secrethub): failing tests --- .../test/secrethub/internal_grpc_api_test.exs | 36 +++++++++++-------- .../open_id_connect/jwt_claim_test.exs | 2 +- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/secrethub/test/secrethub/internal_grpc_api_test.exs b/secrethub/test/secrethub/internal_grpc_api_test.exs index a1e6ca5ec..d8a85bf7e 100644 --- a/secrethub/test/secrethub/internal_grpc_api_test.exs +++ b/secrethub/test/secrethub/internal_grpc_api_test.exs @@ -835,11 +835,12 @@ defmodule Secrethub.InternalGrpcApi.Test do assert Map.get(jwt.fields, "aud") == "https://testera.localhost" assert Map.get(jwt.fields, "iss") == "https://testera.localhost" assert Map.get(jwt.fields, "sub") == "project:front:pipeline:semaphore.yml" - + # Verify sub2 is present and formatted correctly (compact format with comma-separated values) - expected_sub2 = "testera,#{req.project_id},,," # org_username, project_id, empty repo, empty ref_type, empty ref + # org_username, project_id, empty repo, empty ref_type, empty ref + expected_sub2 = "testera,#{req.project_id},,," assert Map.get(jwt.fields, "sub2") == expected_sub2 - + assert Map.get(jwt.fields, "prj") == req.project_name assert Map.get(jwt.fields, "org") == req.org_username refute Map.has_key?(jwt.fields, "https://aws.amazon.com/tags") @@ -913,9 +914,10 @@ defmodule Secrethub.InternalGrpcApi.Test do assert Map.get(jwt.fields, "aud") == "https://testera.localhost" assert Map.get(jwt.fields, "iss") == "https://testera.localhost" assert Map.get(jwt.fields, "sub") == "project:front:pipeline:semaphore.yml" - + # Verify sub2 is present and formatted correctly for AWS tags test - expected_sub2 = "testera,#{req.project_id},my-repo,branch," # org, project_id, repo, ref_type, empty ref + # org, project_id, repo, ref_type, empty ref + expected_sub2 = "testera,#{req.project_id},my-repo,branch," assert Map.get(jwt.fields, "sub2") == expected_sub2 end @@ -1032,9 +1034,10 @@ defmodule Secrethub.InternalGrpcApi.Test do claim_config = ClaimConfig.new( name: "sub2", - description: "Subject identifier", + description: + "Compact subject (check sub) format with comma-separated values only (org,project_id,repo,ref_type,ref)", is_active: true, - is_mandatory: true, + is_mandatory: false, is_aws_tag: false, is_system_claim: true ) @@ -1056,9 +1059,10 @@ defmodule Secrethub.InternalGrpcApi.Test do claim_config = ClaimConfig.new( name: "sub2", - description: "Subject identifier", + description: + "Compact subject (check sub) format with comma-separated values only (org,project_id,repo,ref_type,ref)", is_active: true, - is_mandatory: true, + is_mandatory: false, is_aws_tag: false, is_system_claim: true ) @@ -1153,7 +1157,7 @@ defmodule Secrethub.InternalGrpcApi.Test do description: "Test claim", is_active: true, # can't update for non system claims - is_mandatory: false, + is_mandatory: true, is_aws_tag: false, is_system_claim: false } @@ -1171,9 +1175,10 @@ defmodule Secrethub.InternalGrpcApi.Test do claim_config = ClaimConfig.new( name: "sub2", - description: "Subject identifier", + description: + "Compact subject (check sub) format with comma-separated values only (org,project_id,repo,ref_type,ref)", is_active: true, - is_mandatory: true, + is_mandatory: false, is_aws_tag: false, is_system_claim: true ) @@ -1215,7 +1220,7 @@ defmodule Secrethub.InternalGrpcApi.Test do description: expected_claim.description, is_active: expected_claim.is_active, # can't update for non system claims - is_mandatory: false, + is_mandatory: true, is_aws_tag: false, is_system_claim: false } @@ -1231,7 +1236,8 @@ defmodule Secrethub.InternalGrpcApi.Test do claims: [ ClaimConfig.new( name: "sub2", - description: "Subject identifier", + description: + "Compact subject (check sub) format with comma-separated values only (org,project_id,repo,ref_type,ref)", is_active: true, is_mandatory: true, is_aws_tag: false, @@ -1259,7 +1265,7 @@ defmodule Secrethub.InternalGrpcApi.Test do expected_claim = %{ name: "sub2", - description: "Subject identifier", + description: "Compact subject (check sub) format with comma-separated values only (org,project_id,repo,ref_type,ref)", is_active: true, is_mandatory: false, is_aws_tag: false, diff --git a/secrethub/test/secrethub/open_id_connect/jwt_claim_test.exs b/secrethub/test/secrethub/open_id_connect/jwt_claim_test.exs index 8193375aa..9686c7255 100644 --- a/secrethub/test/secrethub/open_id_connect/jwt_claim_test.exs +++ b/secrethub/test/secrethub/open_id_connect/jwt_claim_test.exs @@ -77,7 +77,7 @@ defmodule Secrethub.OpenIDConnect.JWTClaimTest do assert Map.has_key?(claims, "sub2") sub2_claim = Map.get(claims, "sub2") - + assert sub2_claim.name == "sub2" assert String.contains?(sub2_claim.description, "comma-separated") refute sub2_claim.is_mandatory From 38c43c66b2a6bca90dd314507443129aedd620a4 Mon Sep 17 00:00:00 2001 From: Amir Hasanbasic Date: Tue, 9 Sep 2025 15:34:27 +0200 Subject: [PATCH 03/10] fix(secrethub): credo setup and tests --- secrethub/.credo.exs | 2 +- .../lib/secrethub/open_id_connect/jwt.ex | 3 +- .../test/secrethub/internal_grpc_api_test.exs | 48 +++++++------------ .../open_id_connect/jwt_claim_test.exs | 16 +------ 4 files changed, 21 insertions(+), 48 deletions(-) diff --git a/secrethub/.credo.exs b/secrethub/.credo.exs index c41516255..1c8f066d0 100644 --- a/secrethub/.credo.exs +++ b/secrethub/.credo.exs @@ -46,7 +46,7 @@ # ## Readability Checks # - {Credo.Check.Readability.AliasOrder, exit_status: 0}, + {Credo.Check.Readability.AliasOrder, false}, {Credo.Check.Readability.FunctionNames}, {Credo.Check.Readability.LargeNumbers}, {Credo.Check.Readability.MaxLineLength, priority: :low, max_length: 120}, diff --git a/secrethub/lib/secrethub/open_id_connect/jwt.ex b/secrethub/lib/secrethub/open_id_connect/jwt.ex index 404902808..6f460ff97 100644 --- a/secrethub/lib/secrethub/open_id_connect/jwt.ex +++ b/secrethub/lib/secrethub/open_id_connect/jwt.ex @@ -165,8 +165,7 @@ defmodule Secrethub.OpenIDConnect.JWT do req.git_ref_type, req.git_ref ] - |> Enum.map(&safe_string/1) - |> Enum.join(",") + |> Enum.map_join(":", &safe_string/1) end defp safe_string(nil), do: "" diff --git a/secrethub/test/secrethub/internal_grpc_api_test.exs b/secrethub/test/secrethub/internal_grpc_api_test.exs index d8a85bf7e..307703e87 100644 --- a/secrethub/test/secrethub/internal_grpc_api_test.exs +++ b/secrethub/test/secrethub/internal_grpc_api_test.exs @@ -835,12 +835,7 @@ defmodule Secrethub.InternalGrpcApi.Test do assert Map.get(jwt.fields, "aud") == "https://testera.localhost" assert Map.get(jwt.fields, "iss") == "https://testera.localhost" assert Map.get(jwt.fields, "sub") == "project:front:pipeline:semaphore.yml" - - # Verify sub2 is present and formatted correctly (compact format with comma-separated values) - # org_username, project_id, empty repo, empty ref_type, empty ref - expected_sub2 = "testera,#{req.project_id},,," - assert Map.get(jwt.fields, "sub2") == expected_sub2 - + assert Map.get(jwt.fields, "sub2") == "testera:#{req.project_id}:::" assert Map.get(jwt.fields, "prj") == req.project_name assert Map.get(jwt.fields, "org") == req.org_username refute Map.has_key?(jwt.fields, "https://aws.amazon.com/tags") @@ -914,11 +909,7 @@ defmodule Secrethub.InternalGrpcApi.Test do assert Map.get(jwt.fields, "aud") == "https://testera.localhost" assert Map.get(jwt.fields, "iss") == "https://testera.localhost" assert Map.get(jwt.fields, "sub") == "project:front:pipeline:semaphore.yml" - - # Verify sub2 is present and formatted correctly for AWS tags test - # org, project_id, repo, ref_type, empty ref - expected_sub2 = "testera,#{req.project_id},my-repo,branch," - assert Map.get(jwt.fields, "sub2") == expected_sub2 + assert Map.get(jwt.fields, "sub2") == "testera:#{req.project_id}:my-repo:branch:" end test "it returns a signed token with filtered claims in on_prem mode" do @@ -953,6 +944,7 @@ defmodule Secrethub.InternalGrpcApi.Test do # Essential claims should be present assert Map.get(jwt.fields, "sub") == "project:front:pipeline:semaphore.yml" + assert Map.get(jwt.fields, "sub2") == "testera:#{req.project_id}:my-repo:branch:" assert Map.get(jwt.fields, "aud") == "https://testera.localhost" assert Map.get(jwt.fields, "iss") == "https://testera.localhost" assert_in_delta Map.get(jwt.fields, "exp") + req.expires_in, now, 5 @@ -1033,11 +1025,10 @@ defmodule Secrethub.InternalGrpcApi.Test do } do claim_config = ClaimConfig.new( - name: "sub2", - description: - "Compact subject (check sub) format with comma-separated values only (org,project_id,repo,ref_type,ref)", + name: "custom_claim", + description: "Test custom claim", is_active: true, - is_mandatory: false, + is_mandatory: true, is_aws_tag: false, is_system_claim: true ) @@ -1058,11 +1049,10 @@ defmodule Secrethub.InternalGrpcApi.Test do test "with empty org_id returns error", %{project_id: project_id, channel: channel} do claim_config = ClaimConfig.new( - name: "sub2", - description: - "Compact subject (check sub) format with comma-separated values only (org,project_id,repo,ref_type,ref)", + name: "custom_claim", + description: "Test custom claim", is_active: true, - is_mandatory: false, + is_mandatory: true, is_aws_tag: false, is_system_claim: true ) @@ -1157,7 +1147,7 @@ defmodule Secrethub.InternalGrpcApi.Test do description: "Test claim", is_active: true, # can't update for non system claims - is_mandatory: true, + is_mandatory: false, is_aws_tag: false, is_system_claim: false } @@ -1174,11 +1164,10 @@ defmodule Secrethub.InternalGrpcApi.Test do # Set up initial JWT configuration claim_config = ClaimConfig.new( - name: "sub2", - description: - "Compact subject (check sub) format with comma-separated values only (org,project_id,repo,ref_type,ref)", + name: "custom_claim", + description: "Test custom claim", is_active: true, - is_mandatory: false, + is_mandatory: true, is_aws_tag: false, is_system_claim: true ) @@ -1220,7 +1209,7 @@ defmodule Secrethub.InternalGrpcApi.Test do description: expected_claim.description, is_active: expected_claim.is_active, # can't update for non system claims - is_mandatory: true, + is_mandatory: false, is_aws_tag: false, is_system_claim: false } @@ -1235,9 +1224,8 @@ defmodule Secrethub.InternalGrpcApi.Test do project_id: "", claims: [ ClaimConfig.new( - name: "sub2", - description: - "Compact subject (check sub) format with comma-separated values only (org,project_id,repo,ref_type,ref)", + name: "custom_claim", + description: "Test custom claim", is_active: true, is_mandatory: true, is_aws_tag: false, @@ -1264,8 +1252,8 @@ defmodule Secrethub.InternalGrpcApi.Test do refute is_nil(response.project_id), "Expected project_id not to be nil" expected_claim = %{ - name: "sub2", - description: "Compact subject (check sub) format with comma-separated values only (org,project_id,repo,ref_type,ref)", + name: "custom_claim", + description: "Test custom claim", is_active: true, is_mandatory: false, is_aws_tag: false, diff --git a/secrethub/test/secrethub/open_id_connect/jwt_claim_test.exs b/secrethub/test/secrethub/open_id_connect/jwt_claim_test.exs index 9686c7255..288e2a053 100644 --- a/secrethub/test/secrethub/open_id_connect/jwt_claim_test.exs +++ b/secrethub/test/secrethub/open_id_connect/jwt_claim_test.exs @@ -62,7 +62,7 @@ defmodule Secrethub.OpenIDConnect.JWTClaimTest do claims = JWTClaim.optional_claims() # Check for presence of some workflow-specific claims - workflow_claims = ~w(prj_id wf_id ppl_id job_id repo branch sub sub2) + workflow_claims = ~w(prj_id wf_id ppl_id job_id repo branch) for claim <- workflow_claims do assert Map.has_key?(claims, claim) @@ -72,20 +72,6 @@ defmodule Secrethub.OpenIDConnect.JWTClaimTest do end end - test "optional_claims includes sub2 compact subject claim" do - claims = JWTClaim.optional_claims() - - assert Map.has_key?(claims, "sub2") - sub2_claim = Map.get(claims, "sub2") - - assert sub2_claim.name == "sub2" - assert String.contains?(sub2_claim.description, "comma-separated") - refute sub2_claim.is_mandatory - assert sub2_claim.is_system_claim - refute sub2_claim.is_aws_tag - assert sub2_claim.is_active - end - test "optional_claims includes AWS tag claims" do claims = JWTClaim.optional_claims() From d36f78b4775d69655b8fac8b327889a509992638 Mon Sep 17 00:00:00 2001 From: Dejan K Date: Thu, 23 Oct 2025 11:42:08 +0200 Subject: [PATCH 04/10] feat(secrethub): implement compact subject claim with 127 char limit and field truncation rules --- .../lib/secrethub/open_id_connect/jwt.ex | 80 +++++++++--- .../secrethub/open_id_connect/jwt_claim.ex | 6 +- .../test/secrethub/internal_grpc_api_test.exs | 119 +++++++++++++++++- 3 files changed, 185 insertions(+), 20 deletions(-) diff --git a/secrethub/lib/secrethub/open_id_connect/jwt.ex b/secrethub/lib/secrethub/open_id_connect/jwt.ex index 6f460ff97..23f01554e 100644 --- a/secrethub/lib/secrethub/open_id_connect/jwt.ex +++ b/secrethub/lib/secrethub/open_id_connect/jwt.ex @@ -8,8 +8,8 @@ defmodule Secrethub.OpenIDConnect.JWT do "jti", # Subject of the JWT "sub", - # Compact subject format with comma-separated values only - "sub2", + # Compact subject limited to 127 chars (org:project_id:repo:ref_type:ref) + "sub127", # Recipient for which the JWT is intended "aud", # Issuer of the JWT @@ -56,6 +56,14 @@ defmodule Secrethub.OpenIDConnect.JWT do ] @aws_tags_claim "https://aws.amazon.com/tags" + @max_subject_length 127 + @truncate_rules %{ + org: 25, + project_id: 36, + repo: 25, + ref_type: 2, + ref: 35 + } @algo "RS256" @@ -116,7 +124,7 @@ defmodule Secrethub.OpenIDConnect.JWT do "branch" => req.git_branch_name, "pr" => req.git_pull_request_number, "sub" => req.subject, - "sub2" => build_compact_subject(req), + "sub127" => build_subject_127(req), "iss" => "https://#{req.org_username}.#{domain}", "aud" => "https://#{req.org_username}.#{domain}", "job_type" => req.job_type, @@ -157,18 +165,62 @@ defmodule Secrethub.OpenIDConnect.JWT do Secrethub.OpenIDConnect.JWTFilter.filter_claims(claims, req.org_id, req.project_id) end - defp build_compact_subject(req) do - [ - req.org_username, - req.project_id, - req.repository_name, - req.git_ref_type, + defp build_subject_127(req) do + org = + req.org_username + |> sanitize() + |> cap(:org) + + project = + req.project_id + |> sanitize() + |> cap(:project_id) + + repo = + req.repository_name + |> sanitize() + |> cap(:repo) + + ref_type = + req.git_ref_type + |> sanitize() + |> short_ref_type() + |> cap(:ref_type) + + ref = req.git_ref - ] - |> Enum.map_join(":", &safe_string/1) + |> sanitize() + |> cap(:ref) + + [org, project, repo, ref_type, ref] + |> Enum.join(":") + |> String.slice(0, @max_subject_length) + end + + defp sanitize(nil), do: "" + + defp sanitize(value) when is_binary(value) do + String.replace(value, ":", "") + end + + defp sanitize(value) do + value + |> to_string() + |> sanitize() + end + + defp cap(value, key) do + limit = Map.fetch!(@truncate_rules, key) + String.slice(value, 0, limit) || "" end - defp safe_string(nil), do: "" - defp safe_string(value) when is_binary(value), do: value - defp safe_string(value), do: to_string(value) + defp short_ref_type("branch"), do: "br" + defp short_ref_type("tag"), do: "tg" + defp short_ref_type("pull_request"), do: "pr" + defp short_ref_type("pull-request"), do: "pr" + + defp short_ref_type(value) when is_binary(value) and byte_size(value) > 2, + do: String.slice(value, 0, 2) + + defp short_ref_type(value), do: value end diff --git a/secrethub/lib/secrethub/open_id_connect/jwt_claim.ex b/secrethub/lib/secrethub/open_id_connect/jwt_claim.ex index 0db03d3f7..cfa134526 100644 --- a/secrethub/lib/secrethub/open_id_connect/jwt_claim.ex +++ b/secrethub/lib/secrethub/open_id_connect/jwt_claim.ex @@ -113,10 +113,10 @@ defmodule Secrethub.OpenIDConnect.JWTClaim do is_mandatory: false, is_active: true }, - "sub2" => %__MODULE__{ - name: "sub2", + "sub127" => %__MODULE__{ + name: "sub127", description: - "Compact subject (check sub) format with comma-separated values only (org,project_id,repo,ref_type,ref)", + "Compact subject (org:project_id:repo:ref_type:ref) stripped of ':' characters and capped at 127 chars", is_system_claim: true, is_aws_tag: false, is_mandatory: false, diff --git a/secrethub/test/secrethub/internal_grpc_api_test.exs b/secrethub/test/secrethub/internal_grpc_api_test.exs index 307703e87..b4027472f 100644 --- a/secrethub/test/secrethub/internal_grpc_api_test.exs +++ b/secrethub/test/secrethub/internal_grpc_api_test.exs @@ -835,7 +835,7 @@ defmodule Secrethub.InternalGrpcApi.Test do assert Map.get(jwt.fields, "aud") == "https://testera.localhost" assert Map.get(jwt.fields, "iss") == "https://testera.localhost" assert Map.get(jwt.fields, "sub") == "project:front:pipeline:semaphore.yml" - assert Map.get(jwt.fields, "sub2") == "testera:#{req.project_id}:::" + assert Map.get(jwt.fields, "sub127") == "testera:#{req.project_id}:::" assert Map.get(jwt.fields, "prj") == req.project_name assert Map.get(jwt.fields, "org") == req.org_username refute Map.has_key?(jwt.fields, "https://aws.amazon.com/tags") @@ -909,7 +909,120 @@ defmodule Secrethub.InternalGrpcApi.Test do assert Map.get(jwt.fields, "aud") == "https://testera.localhost" assert Map.get(jwt.fields, "iss") == "https://testera.localhost" assert Map.get(jwt.fields, "sub") == "project:front:pipeline:semaphore.yml" - assert Map.get(jwt.fields, "sub2") == "testera:#{req.project_id}:my-repo:branch:" + assert Map.get(jwt.fields, "sub127") == "testera:#{req.project_id}:my-repo:br:" + end + + test "sub127 claim sanitizes values, caps field lengths, and stays within 127 chars" do + long_org = String.duplicate("org-with:colon:", 10) + long_repo = String.duplicate("repo-with:colon:", 10) + long_ref = String.duplicate("feature/super-long:ref:", 12) + + req = + GenerateOpenIDConnectTokenRequest.new( + org_id: Ecto.UUID.generate(), + org_username: long_org, + expire_in: 3600, + subject: "project:front:pipeline:semaphore.yml", + project_id: Ecto.UUID.generate(), + workflow_id: Ecto.UUID.generate(), + pipeline_id: Ecto.UUID.generate(), + job_id: Ecto.UUID.generate(), + git_branch_name: "master", + repository_name: long_repo, + git_ref_type: "branch", + git_ref: long_ref, + job_type: "pipeline_job", + repo_slug: "renderedtext/front", + triggerer: "api" + ) + + {:ok, channel} = GRPC.Stub.connect("localhost:50051") + + assert {:ok, response} = SecretService.Stub.generate_open_id_connect_token(channel, req) + assert {true, jwt, _} = Secrethub.OpenIDConnect.JWT.verify(response.token) + + claim = Map.fetch!(jwt.fields, "sub127") + parts = String.split(claim, ":", parts: 5, trim: false) + + assert length(parts) == 5 + assert Enum.all?(parts, &(not String.contains?(&1, ":"))) + assert String.length(claim) <= 127 + + expected_org = + long_org + |> String.replace(":", "") + |> String.slice(0, 25) + + expected_repo = + long_repo + |> String.replace(":", "") + |> String.slice(0, 25) + + expected_ref = + long_ref + |> String.replace(":", "") + |> String.slice(0, 35) + + expected = + Enum.join( + [ + expected_org, + String.slice(req.project_id, 0, 36), + expected_repo, + "br", + expected_ref + ], + ":" + ) + + assert claim == expected + assert Enum.at(parts, 0) == expected_org + assert Enum.at(parts, 1) == String.slice(req.project_id, 0, 36) + assert Enum.at(parts, 2) == expected_repo + assert Enum.at(parts, 3) == "br" + assert Enum.at(parts, 4) == expected_ref + assert String.length(Enum.at(parts, 0)) <= 25 + assert String.length(Enum.at(parts, 2)) <= 25 + assert String.length(Enum.at(parts, 4)) <= 35 + end + + test "sub127 claim shortens ref types to two characters" do + org_id = Ecto.UUID.generate() + project_id = Ecto.UUID.generate() + + {:ok, channel} = GRPC.Stub.connect("localhost:50051") + + for {ref_type, expected} <- [ + {"branch", "br"}, + {"tag", "tg"}, + {"pull_request", "pr"}, + {"pull-request", "pr"}, + {"custom", "cu"}, + {"", ""} + ] do + req = + GenerateOpenIDConnectTokenRequest.new( + org_id: org_id, + org_username: "organization", + expire_in: 3600, + subject: "sub", + project_id: project_id, + workflow_id: Ecto.UUID.generate(), + pipeline_id: Ecto.UUID.generate(), + job_id: Ecto.UUID.generate(), + repository_name: "repository", + git_ref_type: ref_type, + git_ref: "feature/example", + job_type: "pipeline_job" + ) + + assert {:ok, response} = SecretService.Stub.generate_open_id_connect_token(channel, req) + assert {true, jwt, _} = Secrethub.OpenIDConnect.JWT.verify(response.token) + + claim = Map.fetch!(jwt.fields, "sub127") + [_org, _project, _repo, actual, _ref] = String.split(claim, ":", parts: 5, trim: false) + assert actual == expected + end end test "it returns a signed token with filtered claims in on_prem mode" do @@ -944,7 +1057,7 @@ defmodule Secrethub.InternalGrpcApi.Test do # Essential claims should be present assert Map.get(jwt.fields, "sub") == "project:front:pipeline:semaphore.yml" - assert Map.get(jwt.fields, "sub2") == "testera:#{req.project_id}:my-repo:branch:" + assert Map.get(jwt.fields, "sub127") == "testera:#{req.project_id}:my-repo:br:" assert Map.get(jwt.fields, "aud") == "https://testera.localhost" assert Map.get(jwt.fields, "iss") == "https://testera.localhost" assert_in_delta Map.get(jwt.fields, "exp") + req.expires_in, now, 5 From 34f417fcb12d51fde5797c242da6f27a242708c4 Mon Sep 17 00:00:00 2001 From: Dejan K Date: Thu, 23 Oct 2025 11:42:32 +0200 Subject: [PATCH 05/10] docs(secrethub): add AGENTS.md and DOCUMENTATION.md files for LLM agents --- secrethub/AGENTS.md | 24 +++++++++++++++++++++ secrethub/DOCUMENTATION.md | 44 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 secrethub/AGENTS.md create mode 100644 secrethub/DOCUMENTATION.md diff --git a/secrethub/AGENTS.md b/secrethub/AGENTS.md new file mode 100644 index 000000000..cbc1aed8e --- /dev/null +++ b/secrethub/AGENTS.md @@ -0,0 +1,24 @@ +# Repository Guidelines + +## Project Structure & Module Organization +Core modules live under `lib/secrethub/**` with the OTP entry point in `lib/secrethub.ex`. Generated gRPC stubs populate `lib/internal_api` and `lib/public_api`; refresh them via the proto Make targets. Configuration sits in `config/*.exs`, assets and keys in `priv/`, and ExUnit tests mirror the tree in `test/**` with helpers in `test/support/`. Versioned `.proto` sources live in `proto/`, with helper scripts under `scripts/`. + +## Build, Test, and Development Commands +- `mix deps.get` installs Elixir dependencies for the current `MIX_ENV`. +- `mix compile` builds the app locally; run before committing generated code. +- `mix test` runs ExUnit; narrow scope with `mix test path/to/file_test.exs`. +- `mix credo --strict` applies the lint rules enforced in `.credo.exs`. +- `make test.ex.setup` boots Docker services, runs migrations, and seeds the test DB. +- `make pb.gen.internal` / `make pb.gen.public` regenerate gRPC stubs via the scripts in `scripts/`. + +## Coding Style & Naming Conventions +Run `mix format` before every commit; it enforces `.formatter.exs` and two-space indentation. Keep modules under the `Secrethub.*` namespace, align pipelines, and name files with `snake_case.ex` and tests with `*_test.exs`. Address Credo findings locally so CI stays clean. + +## Testing Guidelines +Tests use ExUnit with shared helpers in `test/support`. Prefer descriptive names (`test "revokes token"`). Use the provided data-case helpers for database scenarios and add gRPC fixtures under `test/support` when mocking upstream calls. Ensure suites pass with `mix test`; CI toggles the bundled `JUnitFormatter` when needed. + +## Commit & Pull Request Guidelines +History follows conventional commits such as `feat(guard): add posthog integration` or `fix: lock go tool versions`. Start with an imperative summary, include the relevant scope, and reference issues in parentheses (e.g. `(#598)`). Pull requests should explain intent, list validation steps (`mix test`, `mix credo`), and attach screenshots or payload samples for behavioral changes. + +## Security & Configuration Tips +Environment variables defined in the `Makefile` inject credentials for Postgres, RabbitMQ, and external services; never commit real secrets, just use the provided placeholders. Keep generated protobuf code aligned with upstream schemas and audit new dependencies with `mix deps.unlock --check-unused`. diff --git a/secrethub/DOCUMENTATION.md b/secrethub/DOCUMENTATION.md new file mode 100644 index 000000000..56ec81b6f --- /dev/null +++ b/secrethub/DOCUMENTATION.md @@ -0,0 +1,44 @@ +# Internal Architecture Notes + +## What This Service Does +`secrethub` is the semaphore secrets service. It stores encrypted organization, project, and deployment secrets in Postgres, exposes internal gRPC APIs used by other backend services, serves a public gRPC API for agents/CLI, and hosts an OpenID Connect (OIDC) HTTP endpoint that issues scoped JWTs for third parties. Secrets are always persisted encrypted-at-rest and decrypted on demand through the external encryptor service. + +## Runtime Topology +- Entry point: `Secrethub.Application` boots the supervision tree, initialises the feature provider, and conditionally starts gRPC servers, OIDC HTTP, the OpenID key manager, and AMQP consumers based on `START_*` env vars. +- Persistence: `Secrethub.Repo` wraps Postgres via Ecto; migrations live in `priv/repo/migrations`. +- Caches: Cachex stores RBAC permission checks (`:auth_cache`), feature flags, and OIDC usage counters; eviction windows are configured in `application.ex`. +- GRPC servers: `Secrethub.InternalGrpcApi`, `Secrethub.PublicGrpcApi`, and `Secrethub.ProjectSecretsPublicApi` run under `GRPC.Server.Supervisor`. Health checks use `GrpcHealthCheck.Server`. +- OpenID Connect: `lib/secrethub/open_id_connect/**` hosts a Plug stack served by Cowboy plus a `KeyManager` GenServer that persists rotating signing keys under `priv/openid_keys_in_tests` for local runs. + +## Module Map +- Core domain: `lib/secrethub/secret.ex` holds the main Ecto schema and CRUD logic; `project_secrets/**` contains a parallel flow for project-scoped secrets, while `deployment_targets/**` manages deploy target secrets. +- Security helpers: `Secrethub.Auth` talks to the RBAC gRPC service with Cachex-backed memoization; `Secrethub.Encryptor` wraps the external encryptor gRPC API. +- Integrations: `FeatureHubProvider` queries the feature service; `ProjecthubClient` fetches project metadata; `OwnerDeletedConsumer` listens for AMQP deletion events to clean up secrets. +- Utilities: `model/**` defines plain structs used during (de)serialization; `level_gen/**` provides helpers for assembling multi-level env/file payloads; `utils.ex` is a catch-all map transformer used by the gRPC APIs. +- Generated stubs: `lib/internal_api/**` and `lib/public_api/**` are regenerated from the repos referenced in the `Makefile` (`make pb.gen.*`). + +## Data Model Highlights +- `secrets` table stores `content_encrypted`; the decrypted `content` map is virtual and shaped via `Secrethub.Model.Content` with nested `EnvVar` and `File`. +- Project secrets use a separate schema (`project_secrets/secret.ex`) with feature-flag-aware visibility filtering driven by `FeatureProvider`. +- Secret policies include metadata such as `org_config`, `all_projects`, `project_ids`, and audit fields (`created_by`, `used_by`). Access checks combine RBAC results with feature flags before persisting changes. + +## External Services & Configuration +- gRPC endpoints are configured in `config/*.exs`: `:encryptor`, `:feature_api_endpoint`, `:rbac_grpc_endpoint`, `:projecthub_grpc_endpoint`. +- AMQP connection details, Postgres credentials, and service ports are defined in the repo Makefile and surfaced as environment variables (`POSTGRES_DB_*`, `AMQP_URL`, `BASE_DOMAIN`, `START_*` flags). +- Feature flags can be bootstrapped locally by setting `FEATURE_YAML_PATH`, which attaches the configured provider to the supervision tree. + +## Build & Local Development +- Core commands: `mix deps.get`, `mix compile`, `mix test`, `mix credo --strict`. +- Integration flows (DB/protos) rely on Docker: `make test.ex.setup` provisions Postgres/RabbitMQ, runs migrations, and seeds data; `make pb.gen.internal` / `make pb.gen.public` clone the internal API repos and regenerate proto stubs (requires GitHub access and Docker). +- OpenID HTTP port and gRPC ports are read from config (`config/dev.exs`); adjust if ports clash with other services. + +## Testing Aids +- ExUnit helpers live in `test/support`: `DataCase` bootstraps the DB sandbox, `Factories` build fixture structs, and `FakeServices` starts in-process gRPC mocks when `start_stub_grpc_services?/0` evaluates to true (dev/test envs). +- Tests commonly assert on decrypted payloads using `Secrethub.Encryptor.decrypt_secret/1`; prefer the factory helpers to avoid manual encryption. +- CI uses `JUnitFormatter` (configured in `mix.exs`) and expects `mix test` and `mix credo` to pass before merging. + +## Observability & Troubleshooting +- Logging: Sentry backend is registered in `Application.start/2`; ensure SENTRY_DSN is present in non-local envs. +- Metrics: Watchman timings gauge gRPC calls, encryption latency, and feature fetch success/failure (`watchman` client). +- Feature cache invalidation: `FeatureProviderInvalidatorWorker` monitors YAML-backed providers; ensure `FEATURE_YAML_PATH` is mounted in dev if you rely on live refresh. +- When debugging auth issues, inspect `:auth_cache` entries (Cachex) and verify RBAC gRPC connectivity; the cache keys are SHA-256 hashes of the payload. From 2ea9dd4704efd36836f995a2bde9fc38c9e54ce5 Mon Sep 17 00:00:00 2001 From: Dejan K Date: Thu, 23 Oct 2025 11:42:58 +0200 Subject: [PATCH 06/10] chore(secrethub): add bash package and volume mounts for deps and _build in Docker config --- secrethub/Dockerfile | 2 +- secrethub/docker-compose.yml | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/secrethub/Dockerfile b/secrethub/Dockerfile index 1075a4c7e..d8dd44670 100644 --- a/secrethub/Dockerfile +++ b/secrethub/Dockerfile @@ -11,7 +11,7 @@ ENV MIX_ENV=$BUILD_ENV RUN echo "Build for $MIX_ENV environment started" -RUN apk update && apk add --no-cache build-base git python3 curl openssh +RUN apk update && apk add --no-cache build-base git python3 curl bash openssh RUN mkdir -p ~/.ssh RUN touch ~/.ssh/known_hosts diff --git a/secrethub/docker-compose.yml b/secrethub/docker-compose.yml index 6c3215347..e0fac53dd 100644 --- a/secrethub/docker-compose.yml +++ b/secrethub/docker-compose.yml @@ -20,6 +20,8 @@ services: tty: true volumes: - .:/app + - /app/deps + - /app/_build links: - db:db @@ -46,7 +48,7 @@ services: - postgres-data:/var/lib/postgresql/data environment: POSTGRES_PASSWORD: "the-cake-is-a-lie" - + rabbitmq: image: rabbitmq:3-management ports: From e9845bef93bdf47fd81b5a1919aec617f913babf Mon Sep 17 00:00:00 2001 From: Dejan K Date: Thu, 23 Oct 2025 12:14:26 +0200 Subject: [PATCH 07/10] fix(secrethub): trim refs/ prefix from git refs in JWT sub127 claim --- .../lib/secrethub/open_id_connect/jwt.ex | 4 + .../secrethub/open_id_connect/jwt_claim.ex | 2 +- .../test/secrethub/internal_grpc_api_test.exs | 88 ++++++++++++++----- 3 files changed, 69 insertions(+), 25 deletions(-) diff --git a/secrethub/lib/secrethub/open_id_connect/jwt.ex b/secrethub/lib/secrethub/open_id_connect/jwt.ex index 23f01554e..a44916673 100644 --- a/secrethub/lib/secrethub/open_id_connect/jwt.ex +++ b/secrethub/lib/secrethub/open_id_connect/jwt.ex @@ -190,6 +190,7 @@ defmodule Secrethub.OpenIDConnect.JWT do ref = req.git_ref |> sanitize() + |> trim_refs_prefix() |> cap(:ref) [org, project, repo, ref_type, ref] @@ -209,6 +210,9 @@ defmodule Secrethub.OpenIDConnect.JWT do |> sanitize() end + defp trim_refs_prefix("refs/" <> rest), do: rest + defp trim_refs_prefix(value), do: value + defp cap(value, key) do limit = Map.fetch!(@truncate_rules, key) String.slice(value, 0, limit) || "" diff --git a/secrethub/lib/secrethub/open_id_connect/jwt_claim.ex b/secrethub/lib/secrethub/open_id_connect/jwt_claim.ex index cfa134526..4b42840cc 100644 --- a/secrethub/lib/secrethub/open_id_connect/jwt_claim.ex +++ b/secrethub/lib/secrethub/open_id_connect/jwt_claim.ex @@ -116,7 +116,7 @@ defmodule Secrethub.OpenIDConnect.JWTClaim do "sub127" => %__MODULE__{ name: "sub127", description: - "Compact subject (org:project_id:repo:ref_type:ref) stripped of ':' characters and capped at 127 chars", + "Compact subject (org:project_id:repo:ref_type:ref) stripped of ':' characters, ref without leading 'refs/', capped at 127 chars", is_system_claim: true, is_aws_tag: false, is_mandatory: false, diff --git a/secrethub/test/secrethub/internal_grpc_api_test.exs b/secrethub/test/secrethub/internal_grpc_api_test.exs index b4027472f..e68a645e9 100644 --- a/secrethub/test/secrethub/internal_grpc_api_test.exs +++ b/secrethub/test/secrethub/internal_grpc_api_test.exs @@ -798,17 +798,27 @@ defmodule Secrethub.InternalGrpcApi.Test do describe ".generate_openid_connect_token" do test "it returns a signed token, no AWS tags field" do org_id = Ecto.UUID.generate() + project_id = Ecto.UUID.generate() + repo = "web" + ref_type = "branch" + git_ref = "refs/heads/main" req = GenerateOpenIDConnectTokenRequest.new( org_id: org_id, org_username: "testera", expire_in: 3600, - subject: "project:front:pipeline:semaphore.yml", - project_id: Ecto.UUID.generate(), + subject: + "org:testera:project:#{project_id}:repo:#{repo}:ref_type:#{ref_type}:ref:#{git_ref}", + project_id: project_id, workflow_id: Ecto.UUID.generate(), pipeline_id: Ecto.UUID.generate(), job_id: Ecto.UUID.generate(), + repository_name: repo, + git_ref_type: ref_type, + git_ref: git_ref, + git_branch_name: "main", + repo_slug: "renderedtext/#{repo}", job_type: "pipeline_job", project_name: "my-project" ) @@ -834,8 +844,12 @@ defmodule Secrethub.InternalGrpcApi.Test do assert Map.get(jwt.fields, "job_type") == req.job_type assert Map.get(jwt.fields, "aud") == "https://testera.localhost" assert Map.get(jwt.fields, "iss") == "https://testera.localhost" - assert Map.get(jwt.fields, "sub") == "project:front:pipeline:semaphore.yml" - assert Map.get(jwt.fields, "sub127") == "testera:#{req.project_id}:::" + + assert Map.get(jwt.fields, "sub") == req.subject + + assert Map.get(jwt.fields, "sub127") == + "testera:#{project_id}:#{repo}:br:heads/main" + assert Map.get(jwt.fields, "prj") == req.project_name assert Map.get(jwt.fields, "org") == req.org_username refute Map.has_key?(jwt.fields, "https://aws.amazon.com/tags") @@ -844,20 +858,26 @@ defmodule Secrethub.InternalGrpcApi.Test do test "it returns a signed token, with AWS tags field" do Support.FakeServices.enable_features(["open_id_connect_aws_tags"]) org_id = Ecto.UUID.generate() + project_id = Ecto.UUID.generate() + repo = "my-repo" + ref_type = "branch" + git_ref = "refs/heads/main" req = GenerateOpenIDConnectTokenRequest.new( org_id: org_id, org_username: "testera", expire_in: 3600, - subject: "project:front:pipeline:semaphore.yml", - project_id: Ecto.UUID.generate(), + subject: + "org:testera:project:#{project_id}:repo:#{repo}:ref_type:#{ref_type}:ref:#{git_ref}", + project_id: project_id, workflow_id: Ecto.UUID.generate(), pipeline_id: Ecto.UUID.generate(), job_id: Ecto.UUID.generate(), - git_branch_name: "master", - repository_name: "my-repo", - git_ref_type: "branch", + git_branch_name: "main", + repository_name: repo, + git_ref_type: ref_type, + git_ref: git_ref, job_type: "debug_job", repo_slug: "renderedtext/front", triggerer: "h:f,i:f" @@ -886,9 +906,9 @@ defmodule Secrethub.InternalGrpcApi.Test do assert Map.get(jwt.fields, "https://aws.amazon.com/tags") == %{ "principal_tags" => %{ "prj_id" => [req.project_id], - "repo" => [req.repository_name], + "repo" => [repo], "branch" => [req.git_branch_name], - "ref_type" => [req.git_ref_type], + "ref_type" => [ref_type], "job_type" => [req.job_type], "pr_branch" => [""], "repo_slug" => [req.repo_slug], @@ -908,22 +928,27 @@ defmodule Secrethub.InternalGrpcApi.Test do assert Map.get(jwt.fields, "aud") == "https://testera.localhost" assert Map.get(jwt.fields, "iss") == "https://testera.localhost" - assert Map.get(jwt.fields, "sub") == "project:front:pipeline:semaphore.yml" - assert Map.get(jwt.fields, "sub127") == "testera:#{req.project_id}:my-repo:br:" + + assert Map.get(jwt.fields, "sub") == req.subject + + assert Map.get(jwt.fields, "sub127") == + "testera:#{project_id}:#{repo}:br:heads/main" end - test "sub127 claim sanitizes values, caps field lengths, and stays within 127 chars" do + test "sub127 claim sanitizes values, trims refs/ prefix, caps lengths, and stays within 127 chars" do long_org = String.duplicate("org-with:colon:", 10) long_repo = String.duplicate("repo-with:colon:", 10) - long_ref = String.duplicate("feature/super-long:ref:", 12) + long_ref = "refs/" <> String.duplicate("feature/super-long:ref/", 12) + project_id = Ecto.UUID.generate() req = GenerateOpenIDConnectTokenRequest.new( org_id: Ecto.UUID.generate(), org_username: long_org, expire_in: 3600, - subject: "project:front:pipeline:semaphore.yml", - project_id: Ecto.UUID.generate(), + subject: + "org:#{long_org}:project:#{project_id}:repo:#{long_repo}:ref_type:branch:ref:#{long_ref}", + project_id: project_id, workflow_id: Ecto.UUID.generate(), pipeline_id: Ecto.UUID.generate(), job_id: Ecto.UUID.generate(), @@ -958,9 +983,13 @@ defmodule Secrethub.InternalGrpcApi.Test do |> String.replace(":", "") |> String.slice(0, 25) - expected_ref = + expected_ref_full = long_ref |> String.replace(":", "") + |> String.replace_prefix("refs/", "") + + expected_ref = + expected_ref_full |> String.slice(0, 35) expected = @@ -984,6 +1013,8 @@ defmodule Secrethub.InternalGrpcApi.Test do assert String.length(Enum.at(parts, 0)) <= 25 assert String.length(Enum.at(parts, 2)) <= 25 assert String.length(Enum.at(parts, 4)) <= 35 + + assert Map.fetch!(jwt.fields, "sub") == req.subject end test "sub127 claim shortens ref types to two characters" do @@ -1028,20 +1059,26 @@ defmodule Secrethub.InternalGrpcApi.Test do test "it returns a signed token with filtered claims in on_prem mode" do Support.FakeServices.enable_features(["open_id_connect_aws_tags", "open_id_connect_filter"]) org_id = Ecto.UUID.generate() + project_id = Ecto.UUID.generate() + repo = "my-repo" + ref_type = "branch" + git_ref = "refs/heads/master" req = GenerateOpenIDConnectTokenRequest.new( org_id: org_id, org_username: "testera", expire_in: 3600, - subject: "project:front:pipeline:semaphore.yml", - project_id: Ecto.UUID.generate(), + subject: + "org:testera:project:#{project_id}:repo:#{repo}:ref_type:#{ref_type}:ref:#{git_ref}", + project_id: project_id, workflow_id: Ecto.UUID.generate(), pipeline_id: Ecto.UUID.generate(), job_id: Ecto.UUID.generate(), git_branch_name: "master", - repository_name: "my-repo", - git_ref_type: "branch", + repository_name: repo, + git_ref_type: ref_type, + git_ref: git_ref, job_type: "debug_job", repo_slug: "renderedtext/front", triggerer: "h:f-i:f", @@ -1056,8 +1093,11 @@ defmodule Secrethub.InternalGrpcApi.Test do now = epoch() # Essential claims should be present - assert Map.get(jwt.fields, "sub") == "project:front:pipeline:semaphore.yml" - assert Map.get(jwt.fields, "sub127") == "testera:#{req.project_id}:my-repo:br:" + assert Map.get(jwt.fields, "sub") == req.subject + + assert Map.get(jwt.fields, "sub127") == + "testera:#{project_id}:#{repo}:br:heads/master" + assert Map.get(jwt.fields, "aud") == "https://testera.localhost" assert Map.get(jwt.fields, "iss") == "https://testera.localhost" assert_in_delta Map.get(jwt.fields, "exp") + req.expires_in, now, 5 From f8ae02e48522b1b68b5afda8d1085713b6a7e5d5 Mon Sep 17 00:00:00 2001 From: Dejan K Date: Thu, 23 Oct 2025 12:29:52 +0200 Subject: [PATCH 08/10] docs(docs): add AGENTS.md and DOCUMENTATION.md files for LLM agents --- docs/AGENTS.md | 20 ++++++++++++++++++++ docs/DOCUMENTATION.md | 44 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 docs/AGENTS.md create mode 100644 docs/DOCUMENTATION.md diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 000000000..0dad8caf3 --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,20 @@ +# Repository Guidelines + +## Project Structure & Module Organization +The docs site runs on Docusaurus. Author new or updated guides in `docs/` and keep contributor-facing notes in `docs-contributing/`. Historical content lives in `versioned_docs/`, paired with `versioned_sidebars/` for navigation metadata. Custom React or styling tweaks reside in `src/` (`components/`, `pages/`, `theme/`), while shared assets such as images, downloads, and redirects belong under `static/`. Park work-in-progress material inside `docs-drafts/` to prevent accidental publication. + +## Build, Test, and Development Commands +Run `npm start` (or `make npm.dev`) to spin up the live-reloading docs server on `localhost:3000`. `npm run build` (mirrored by `make npm.build`) generates the production-ready static bundle in `build/` and surfaces MDX or sidebar errors. Enforce content quality with `npm run lint` or `make npm.lint`, which executes `markdownlint-cli2` across `docs/**` and `versioned_docs/**`. + +```bash +npm run lint +``` + +## Coding Style & Naming Conventions +Use YAML front matter with at least `title` (and `slug` or `sidebar_position` when needed) to keep navigation orderly. Write concise Markdown/MDX in sentence case headings, wrap prose near 100 characters to ease reviews, and prefer lists or tables for procedural steps. Tag code fences with a language hint and surface commands with shell blocks. Name reusable MDX components in PascalCase and place them under `src/components/`, and store shared assets in kebab-case filenames (for example `rolling-deploy.png`). + +## Testing Guidelines +Run `npm run lint` before pushing; suppress rules only when `.markdownlint.json` documents the exception. Follow with `npm run build` to catch broken imports, invalid images, or sidebar regressions. For parity with containerized checks, `make test` executes the nginx configuration validation used in CI. + +## Commit & Pull Request Guidelines +Use the Conventional Commits style seen in history—`docs(scope): concise summary`—to keep automation predictable. Reference issues or Linear tickets in the body, describe the user-facing impact, and call out the docs sections affected. Include screenshots or GIFs when the UI or illustrations change, and add deploy preview URLs so reviewers can validate navigation, search, and syntax highlighting interactively. diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md new file mode 100644 index 000000000..15e7d0c3d --- /dev/null +++ b/docs/DOCUMENTATION.md @@ -0,0 +1,44 @@ +# Docs Workspace Architecture + +## Tech Stack Snapshot +- Docusaurus 3 (`@docusaurus/core` 3.9.x) with the classic preset, Algolia search, Mermaid diagrams, and `docusaurus-theme-openapi-docs`. +- React 18 components backed by Prism for code highlighting (GitHub/Dracula themes plus Elixir, Java, Groovy extra languages). +- Builds require Node ≥18 and rely on `markdownlint-cli2` for Markdown quality checks. +- The Dockerfile performs a multi-stage build: Node-based asset compilation followed by an Nginx runtime image that serves `/usr/share/nginx/html`. + +## Directory Layout & Ownership +- `docs/`: Active Cloud (current) documentation. Keep front matter (`title`, optional `slug`, `sidebar_position`) with sentence-case headings. Subfolders mirror sidebar categories (see `sidebars.js`). +- `versioned_docs/`: Frozen snapshots per edition (`version-CE`, `version-EE`, etc.). Generated via `npm run docusaurus docs:version