Skip to content

Commit e3df37f

Browse files
committed
feat(cli): select attestation organization from repository config
Apply repository organization selection to attestation commands before connecting, preserve explicit and saved choices, and reject mismatched API tokens without changing the saved default. Implements R-003, R-005, R-006, R-009, and the organization portion of R-004. Refs: #3504 Assisted-by: pi Signed-off-by: Vibhav Bobade <vibhav.bobde@gmail.com>
1 parent 8c9113e commit e3df37f

10 files changed

Lines changed: 467 additions & 64 deletions

File tree

‎app/cli/cmd/attestation.go‎

Lines changed: 74 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import (
2020
"os"
2121
"strings"
2222

23+
"github.com/chainloop-dev/chainloop/app/cli/internal/repositoryconfig"
24+
internaltoken "github.com/chainloop-dev/chainloop/app/cli/internal/token"
2325
"github.com/chainloop-dev/chainloop/app/cli/pkg/action"
2426
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter"
2527
v1 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
@@ -28,6 +30,15 @@ import (
2830
"google.golang.org/protobuf/encoding/protojson"
2931
)
3032

33+
const (
34+
attestationOrganizationNameAnnotation = "chainloop.attestation.organization"
35+
attestationOrganizationFileAnnotation = "chainloop.attestation.organization-file"
36+
)
37+
38+
var repositoryOrganizationCommands = map[string]bool{
39+
"init": true, "add": true, "push": true, "status": true, "reset": true,
40+
}
41+
3142
var (
3243
useAttestationRemoteState bool
3344
attestationLocalStatePath string
@@ -44,14 +55,8 @@ func newAttestationCmd() *cobra.Command {
4455
Short: "Craft Software Supply Chain Attestations",
4556
Example: "Refer to https://docs.chainloop.dev/getting-started/attestation-crafting",
4657
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
47-
// For non-init attestation subcommands using local state, load the org
48-
// from the crafting state so the gRPC connection uses the correct org header.
49-
// This handles the case where `init --org X` was used but subsequent commands
50-
// would otherwise fall back to the default org from the config file.
51-
if orgFlag := cmd.Root().PersistentFlags().Lookup(confOptions.organization.flagName); cmd.Name() != "init" && !useAttestationRemoteState && (orgFlag == nil || !orgFlag.Changed) {
52-
if org := orgFromLocalState(attestationLocalStatePath); org != "" {
53-
viper.Set(confOptions.organization.viperKey, org)
54-
}
58+
if err := resolveAttestationOrganization(cmd); err != nil {
59+
return err
5560
}
5661

5762
// run the initialization of the root command plus the new logic
@@ -84,6 +89,67 @@ func newAttestationCmd() *cobra.Command {
8489
return cmd
8590
}
8691

92+
func resolveAttestationOrganization(cmd *cobra.Command) error {
93+
orgFlag := cmd.Root().PersistentFlags().Lookup(confOptions.organization.flagName)
94+
explicitOrganization := (orgFlag != nil && orgFlag.Changed) || os.Getenv(CalculateEnvVarName(confOptions.organization.viperKey)) != ""
95+
if explicitOrganization {
96+
return nil
97+
}
98+
99+
if repositoryOrganizationCommands[cmd.Name()] {
100+
cfg, path, err := repositoryconfig.LoadChainloopYML(".")
101+
if err == nil && cfg.Organization != "" {
102+
authToken, _, err := loadAuthToken(cmd)
103+
if err != nil {
104+
return err
105+
}
106+
if tokenOrg, isAPI := internaltoken.APIOrganization(authToken); isAPI {
107+
switch {
108+
case tokenOrg == "":
109+
return fmt.Errorf("API token does not identify its organization: create a new API token for %q or remove organization from %s", cfg.Organization, path)
110+
case !strings.EqualFold(tokenOrg, cfg.Organization):
111+
return fmt.Errorf("credentials belong to organization %q but %q is configured in %s: use an API token for %q or remove organization from %s", tokenOrg, cfg.Organization, path, cfg.Organization, path)
112+
}
113+
}
114+
115+
setAttestationOrganization(cmd, cfg.Organization, path)
116+
if saved := viper.GetString(confOptions.organization.viperKey); saved != cfg.Organization {
117+
logger.Info().Str("organization", cfg.Organization).Str("file", path).Msg("using organization from repository config")
118+
}
119+
return nil
120+
}
121+
if err != nil {
122+
logger.Debug().Err(err).Msg("failed to load repository organization")
123+
}
124+
}
125+
126+
// Preserve the existing local-state fallback for commands other than init.
127+
if cmd.Name() != "init" && !useAttestationRemoteState {
128+
if org := orgFromLocalState(attestationLocalStatePath); org != "" {
129+
setAttestationOrganization(cmd, org, "")
130+
}
131+
}
132+
133+
return nil
134+
}
135+
136+
func setAttestationOrganization(cmd *cobra.Command, organization, path string) {
137+
if cmd.Annotations == nil {
138+
cmd.Annotations = make(map[string]string)
139+
}
140+
cmd.Annotations[attestationOrganizationNameAnnotation] = organization
141+
if path != "" {
142+
cmd.Annotations[attestationOrganizationFileAnnotation] = path
143+
}
144+
}
145+
146+
func attestationOrganization(cmd *cobra.Command) (organization, path string) {
147+
if cmd == nil {
148+
return "", ""
149+
}
150+
return cmd.Annotations[attestationOrganizationNameAnnotation], cmd.Annotations[attestationOrganizationFileAnnotation]
151+
}
152+
87153
func flagAttestationID(cmd *cobra.Command) {
88154
cmd.Flags().StringVar(&attestationID, "attestation-id", "", "Unique identifier of the in-progress attestation")
89155
}

‎app/cli/cmd/attestation_test.go‎

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,19 @@
1616
package cmd
1717

1818
import (
19+
"bytes"
1920
"os"
2021
"path/filepath"
22+
"strings"
2123
"testing"
2224

25+
controlplanev1 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
2326
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter"
2427
v1 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
28+
"github.com/golang-jwt/jwt/v5"
29+
"github.com/rs/zerolog"
30+
"github.com/spf13/cobra"
31+
"github.com/spf13/viper"
2532
"github.com/stretchr/testify/assert"
2633
"github.com/stretchr/testify/require"
2734
"google.golang.org/protobuf/encoding/protojson"
@@ -106,6 +113,222 @@ func TestAttestationInitValidatesFlagsRegardlessOfRepositoryConfig(t *testing.T)
106113
}
107114
}
108115

116+
func TestAttestationRepositoryOrganizationCommands(t *testing.T) {
117+
dir := t.TempDir()
118+
configPath := filepath.Join(dir, ".chainloop.yml")
119+
require.NoError(t, os.WriteFile(configPath, []byte("organization: repository-org\n"), 0o600))
120+
configPath, err := filepath.EvalSymlinks(configPath)
121+
require.NoError(t, err)
122+
t.Chdir(dir)
123+
124+
for _, name := range []string{"init", "add", "push", "status", "reset"} {
125+
t.Run(name, func(t *testing.T) {
126+
_, cmd := newAttestationOrganizationTestCommand(t, name, "saved-org")
127+
require.NoError(t, resolveAttestationOrganization(cmd))
128+
129+
organization, path := attestationOrganization(cmd)
130+
assert.Equal(t, "repository-org", organization)
131+
assert.Equal(t, configPath, path)
132+
assert.Equal(t, "repository-org", effectiveOrganization(cmd))
133+
assert.Equal(t, "saved-org", viper.GetString(confOptions.organization.viperKey))
134+
})
135+
}
136+
}
137+
138+
func TestAttestationOrganizationPrecedence(t *testing.T) {
139+
for _, tc := range []struct {
140+
name string
141+
flag string
142+
environment string
143+
repository string
144+
local string
145+
want string
146+
}{
147+
{name: "flag", flag: "flag-org", environment: "environment-org", repository: "repository-org", local: "local-org", want: "flag-org"},
148+
{name: "environment", environment: "environment-org", repository: "repository-org", local: "local-org", want: "environment-org"},
149+
{name: "repository", repository: "repository-org", local: "local-org", want: "repository-org"},
150+
{name: "local state", local: "local-org", want: "local-org"},
151+
{name: "saved default", want: "saved-org"},
152+
} {
153+
t.Run(tc.name, func(t *testing.T) {
154+
dir := t.TempDir()
155+
if tc.repository != "" {
156+
require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), []byte("organization: "+tc.repository+"\n"), 0o600))
157+
}
158+
t.Chdir(dir)
159+
160+
root, cmd := newAttestationOrganizationTestCommand(t, "status", "saved-org")
161+
t.Setenv(CalculateEnvVarName(confOptions.organization.viperKey), tc.environment)
162+
if tc.flag != "" {
163+
require.NoError(t, root.PersistentFlags().Set(confOptions.organization.flagName, tc.flag))
164+
}
165+
if tc.local != "" {
166+
attestationLocalStatePath = writeAttestationOrganizationState(t, tc.local)
167+
}
168+
169+
require.NoError(t, resolveAttestationOrganization(cmd))
170+
assert.Equal(t, tc.want, effectiveOrganization(cmd))
171+
})
172+
}
173+
}
174+
175+
func TestAttestationVerifyDoesNotReadRepositoryOrganization(t *testing.T) {
176+
dir := t.TempDir()
177+
require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), []byte("organization: repository-org\n"), 0o600))
178+
t.Chdir(dir)
179+
180+
_, cmd := newAttestationOrganizationTestCommand(t, "verify", "saved-org")
181+
require.NoError(t, resolveAttestationOrganization(cmd))
182+
183+
organization, path := attestationOrganization(cmd)
184+
assert.Empty(t, organization)
185+
assert.Empty(t, path)
186+
assert.Equal(t, "saved-org", effectiveOrganization(cmd))
187+
}
188+
189+
func TestAttestationRepositoryOrganizationTokenMismatchStopsBeforeRootPreRun(t *testing.T) {
190+
dir := t.TempDir()
191+
configPath := filepath.Join(dir, ".chainloop.yml")
192+
require.NoError(t, os.WriteFile(configPath, []byte("organization: repository-org\n"), 0o600))
193+
configPath, err := filepath.EvalSymlinks(configPath)
194+
require.NoError(t, err)
195+
t.Chdir(dir)
196+
197+
root, cmd := newAttestationOrganizationTestCommand(t, "status", "saved-org")
198+
rootPreRunCalled := false
199+
root.PersistentPreRunE = func(*cobra.Command, []string) error {
200+
rootPreRunCalled = true
201+
return nil
202+
}
203+
204+
claims := jwt.MapClaims{"aud": "api-token-auth.chainloop", "jti": "token-id", "org_name": "token-org"}
205+
rawToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte("secret"))
206+
require.NoError(t, err)
207+
apiToken = rawToken
208+
209+
err = cmd.Parent().PersistentPreRunE(cmd, nil)
210+
require.Error(t, err)
211+
assert.False(t, rootPreRunCalled)
212+
assert.ErrorContains(t, err, "token-org")
213+
assert.ErrorContains(t, err, "repository-org")
214+
assert.ErrorContains(t, err, "use an API token for")
215+
assert.ErrorContains(t, err, "remove organization")
216+
assert.ErrorContains(t, err, configPath)
217+
}
218+
219+
func TestAttestationRepositoryOrganizationLegacyTokenStopsBeforeRootPreRun(t *testing.T) {
220+
dir := t.TempDir()
221+
configPath := filepath.Join(dir, ".chainloop.yml")
222+
require.NoError(t, os.WriteFile(configPath, []byte("organization: repository-org\n"), 0o600))
223+
configPath, err := filepath.EvalSymlinks(configPath)
224+
require.NoError(t, err)
225+
t.Chdir(dir)
226+
227+
root, cmd := newAttestationOrganizationTestCommand(t, "status", "saved-org")
228+
rootPreRunCalled := false
229+
root.PersistentPreRunE = func(*cobra.Command, []string) error {
230+
rootPreRunCalled = true
231+
return nil
232+
}
233+
234+
claims := jwt.MapClaims{"aud": "api-token-auth.chainloop", "jti": "legacy-token-id"}
235+
rawToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte("secret"))
236+
require.NoError(t, err)
237+
apiToken = rawToken
238+
239+
err = cmd.Parent().PersistentPreRunE(cmd, nil)
240+
require.Error(t, err)
241+
assert.False(t, rootPreRunCalled)
242+
assert.ErrorContains(t, err, "does not identify its organization")
243+
assert.ErrorContains(t, err, "create a new API token for \"repository-org\"")
244+
assert.ErrorContains(t, err, "remove organization")
245+
assert.ErrorContains(t, err, configPath)
246+
}
247+
248+
func TestAttestationRepositoryOrganizationMembershipErrorPreservesSavedDefault(t *testing.T) {
249+
_, cmd := newAttestationOrganizationTestCommand(t, "status", "saved-org")
250+
path := filepath.Join(t.TempDir(), ".chainloop.yml")
251+
setAttestationOrganization(cmd, "repository-org", path)
252+
253+
err := handleOrganizationExecutionError(cmd, controlplanev1.ErrorUserNotMemberOfOrgErrorNotInOrg("not a member"))
254+
require.Error(t, err)
255+
assert.ErrorContains(t, err, path)
256+
assert.Equal(t, "saved-org", viper.GetString(confOptions.organization.viperKey))
257+
}
258+
259+
func TestAttestationRepositoryOrganizationNotice(t *testing.T) {
260+
dir := t.TempDir()
261+
configPath := filepath.Join(dir, ".chainloop.yml")
262+
require.NoError(t, os.WriteFile(configPath, []byte("organization: repository-org\n"), 0o600))
263+
configPath, err := filepath.EvalSymlinks(configPath)
264+
require.NoError(t, err)
265+
t.Chdir(dir)
266+
267+
_, cmd := newAttestationOrganizationTestCommand(t, "status", "saved-org")
268+
var output bytes.Buffer
269+
previousLogger := logger
270+
logger = zerolog.New(&output)
271+
t.Cleanup(func() { logger = previousLogger })
272+
require.NoError(t, resolveAttestationOrganization(cmd))
273+
274+
assert.Equal(t, 1, strings.Count(output.String(), "using organization from repository config"))
275+
assert.Contains(t, output.String(), "repository-org")
276+
assert.Contains(t, output.String(), configPath)
277+
assert.Equal(t, "saved-org", viper.GetString(confOptions.organization.viperKey))
278+
}
279+
280+
func newAttestationOrganizationTestCommand(t *testing.T, command, savedOrganization string) (*cobra.Command, *cobra.Command) {
281+
t.Helper()
282+
283+
viper.Reset()
284+
t.Cleanup(viper.Reset)
285+
t.Setenv(tokenEnvVarName, "")
286+
t.Setenv(CalculateEnvVarName(confOptions.organization.viperKey), "")
287+
288+
previousAPIToken := apiToken
289+
previousLocalStatePath := attestationLocalStatePath
290+
previousRemoteState := useAttestationRemoteState
291+
previousLogger := logger
292+
apiToken = ""
293+
attestationLocalStatePath = filepath.Join(t.TempDir(), "missing-state.json")
294+
useAttestationRemoteState = false
295+
logger = zerolog.Nop()
296+
t.Cleanup(func() {
297+
apiToken = previousAPIToken
298+
attestationLocalStatePath = previousLocalStatePath
299+
useAttestationRemoteState = previousRemoteState
300+
logger = previousLogger
301+
})
302+
303+
viper.SetConfigType("toml")
304+
require.NoError(t, viper.ReadConfig(strings.NewReader("organization = \""+savedOrganization+"\"\n")))
305+
306+
root := &cobra.Command{Use: appName, PersistentPreRunE: func(*cobra.Command, []string) error { return nil }}
307+
root.PersistentFlags().String(confOptions.organization.flagName, "", "")
308+
require.NoError(t, viper.BindPFlag(confOptions.organization.viperKey, root.PersistentFlags().Lookup(confOptions.organization.flagName)))
309+
require.NoError(t, viper.BindEnv(confOptions.organization.viperKey, CalculateEnvVarName(confOptions.organization.viperKey)))
310+
311+
attestationCmd := newAttestationCmd()
312+
root.AddCommand(attestationCmd)
313+
for _, candidate := range attestationCmd.Commands() {
314+
if candidate.Name() == command {
315+
return root, candidate
316+
}
317+
}
318+
require.FailNowf(t, "attestation command not found", "command: %q", command)
319+
return nil, nil
320+
}
321+
322+
func writeAttestationOrganizationState(t *testing.T, organization string) string {
323+
t.Helper()
324+
state := &crafter.VersionedCraftingState{CraftingState: &v1.CraftingState{Attestation: &v1.Attestation{Workflow: &v1.WorkflowMetadata{Organization: organization}}}}
325+
raw, err := protojson.Marshal(state)
326+
require.NoError(t, err)
327+
path := filepath.Join(t.TempDir(), "state.json")
328+
require.NoError(t, os.WriteFile(path, raw, 0o600))
329+
return path
330+
}
331+
109332
func TestExtractAnnotations(t *testing.T) {
110333
testCases := []struct {
111334
input []string

0 commit comments

Comments
 (0)