Skip to content

Commit fe94e74

Browse files
committed
feat: add a cli utility for managing clients
1 parent 15e85fb commit fe94e74

File tree

10 files changed

+620
-3
lines changed

10 files changed

+620
-3
lines changed

diode-server/Makefile

+16-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
SERVICES := $(shell find ./cmd/* -type d -exec basename {} \;)
1+
UTILITIES = auth_manage
2+
BUILD_UTILITIES = $(addprefix build-,$(UTILITIES))
3+
SERVICES := $(filter-out $(UTILITIES),$(shell find ./cmd/* -type d -exec basename {} \;))
24
BUILD_SERVICES = $(addprefix build-,$(SERVICES))
35
DOCKER_SERVICES = $(addprefix docker-,$(SERVICES))
46
BUILD_DIR ?= ./build
@@ -58,11 +60,17 @@ $(BUILD_SERVICES):
5860
@CGO_ENABLED=$(CGO_ENABLED) GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) \
5961
go build -ldflags "$(LD_FLAGS)" -o $(BUILD_DIR)/$(SVC) ./cmd/$(SVC)
6062

63+
.PHONY: $(BUILD_UTILITIES)
64+
$(BUILD_UTILITIES):
65+
@$(eval UTIL=$(subst build-,,$@))
66+
@CGO_ENABLED=$(CGO_ENABLED) GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) \
67+
go build -ldflags "$(LD_FLAGS)" -o $(BUILD_DIR)/$(UTIL) ./cmd/$(UTIL)
68+
6169
.PHONY: build-all
62-
build-all: $(BUILD_SERVICES)
70+
build-all: $(BUILD_SERVICES) $(BUILD_UTILITIES)
6371

6472
.PHONY: $(DOCKER_SERVICES)
65-
$(DOCKER_SERVICES):
73+
$(DOCKER_SERVICES): $(BUILD_UTILITIES)
6674
@$(eval SVC=$(subst docker-,,$@))
6775

6876
@GOOS=$(GOOS) GOARCH=$(GOARCH) $(MAKE) build-$(SVC)
@@ -104,6 +112,11 @@ docker-compose-dev-down:
104112
@DIODE_VERSION=$(DIODE_VERSION) COMMIT_SHA=$(COMMIT_SHA) DIODE_TAG=$(DIODE_VERSION)-$(COMMIT_SHA) PROJECT_NAME=diode-dev \
105113
$(DOCKER_COMPOSE) --env-file docker/sample.env --env-file docker/dev.env -f docker/docker-compose.yaml -f docker/docker-compose.dev.yaml down --remove-orphans
106114

115+
.PHONY: docker-compose-dev-auth-manage
116+
docker-compose-dev-auth-manage:
117+
@DIODE_VERSION=$(DIODE_VERSION) COMMIT_SHA=$(COMMIT_SHA) DIODE_TAG=$(DIODE_VERSION)-$(COMMIT_SHA) PROJECT_NAME=diode-dev \
118+
$(DOCKER_COMPOSE) --env-file docker/sample.env --env-file docker/dev.env -f docker/docker-compose.yaml -f docker/docker-compose.dev.yaml run --rm --no-deps diode-auth auth_manage $(auth_manage_command)
119+
107120
.PHONY: clean
108121
clean:
109122
@rm -rf $(BUILD_DIR)/*

diode-server/README.md

+112
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,118 @@ docker compose down --volumes
118118

119119
---
120120

121+
### Provisioning and Managing Agent Credentials via Comand Line
122+
123+
Additional agent credentials may be provisioned by calling the `auth_manage` command in the auth service container.
124+
125+
**Create a new client with the right to ingest**
126+
```bash
127+
docker compose run --rm --no-deps diode-auth auth_manage create-client --client-id my-agent-001 --allow-ingest
128+
```
129+
example output:
130+
```
131+
** NOTE: The client secret is only displayed once and cannot be retrieved later.
132+
Store credentials in a secure location. If you lose them, you will need to
133+
destroy and regenerate the client.
134+
{
135+
"client_id": "my-agent-001",
136+
"scope": "diode:ingest",
137+
"client_secret": "a_new_generated_secret"
138+
}
139+
```
140+
141+
**Create a client with a supplied secret** (in this case the secret is not returned)
142+
```bash
143+
docker compose run --rm --no-deps diode-auth auth_manage create-client --client-id my-agent-002 --allow-ingest --client-secret="a_secret_key_from_some_other_source"
144+
```
145+
example output:
146+
```
147+
client created successfully.
148+
{
149+
"client_id": "my-agent-002",
150+
"scope": "diode:ingest"
151+
}
152+
```
153+
154+
**Retrieve info of an existing client**
155+
```bash
156+
docker compose run --rm --no-deps diode-auth auth_manage get-client --client-id my-agent-001
157+
```
158+
example output:
159+
```
160+
{
161+
"client_id": "my-agent-001",
162+
"scope": "diode:ingest"
163+
}
164+
```
165+
166+
**List existing clients**
167+
```bash
168+
docker compose run --rm --no-deps diode-auth auth_manage list-clients
169+
```
170+
example output:
171+
```
172+
[
173+
{
174+
"client_id": "diode-ingest",
175+
"scope": "diode:ingest"
176+
},
177+
{
178+
"client_id": "diode-to-netbox",
179+
"scope": "netbox:read netbox:write"
180+
},
181+
{
182+
"client_id": "my-agent-001",
183+
"scope": "diode:ingest"
184+
},
185+
{
186+
"client_id": "my-agent-002",
187+
"scope": "diode:ingest"
188+
},
189+
{
190+
"client_id": "netbox-to-diode",
191+
"scope": "diode:read diode:write"
192+
}
193+
]
194+
```
195+
196+
**Delete an existing client**
197+
```bash
198+
docker compose run --rm --no-deps diode-auth auth_manage delete-client --client-id my-agent-002
199+
```
200+
example output:
201+
```
202+
client my-agent-002 deleted successfully
203+
```
204+
205+
**List auth utility subcommands**
206+
```bash
207+
docker compose run --rm --no-deps diode-auth auth_manage
208+
```
209+
example output:
210+
```
211+
usage: auth_manage <subcommand>
212+
subcommands: list-clients get-client delete-client create-client
213+
```
214+
215+
**Additional help on a subcommand**
216+
```bash
217+
docker compose run --rm --no-deps diode-auth auth_manage create-client --help
218+
```
219+
example output:
220+
```
221+
Usage of create-client:
222+
-allow-ingest
223+
include scopes that allow the client to ingest data
224+
-client-id string
225+
client id
226+
-client-secret string
227+
client secret [generated if not provided]
228+
-scope string
229+
space separated list of scopes to allow
230+
```
231+
---
232+
121233
## License
122234

123235
Distributed under the NetBox Limited Use License 1.0.

diode-server/auth/manage.go

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package auth
2+
3+
import (
4+
"context"
5+
"crypto/rand"
6+
"encoding/base64"
7+
)
8+
9+
type ClientInfo struct {
10+
ClientID string `json:"client_id"`
11+
Scope string `json:"scope"`
12+
ClientSecret string `json:"client_secret,omitempty"`
13+
}
14+
15+
type RetrieveClientsRequest struct {
16+
PageToken string
17+
}
18+
19+
type RetrieveClientsResponse struct {
20+
Clients []ClientInfo
21+
NextPageToken string
22+
}
23+
24+
// ClientManager is an interface for managing oauth2 clients.
25+
type ClientManager interface {
26+
CreateClient(ctx context.Context, clientInfo ClientInfo) (ClientInfo, error)
27+
RetrieveClientByID(ctx context.Context, clientID string) (ClientInfo, error)
28+
RetrieveClients(ctx context.Context, q RetrieveClientsRequest) (RetrieveClientsResponse, error)
29+
DeleteClientByID(ctx context.Context, clientID string) error
30+
}
31+
32+
func GenerateClientSecret() (string, error) {
33+
secret := make([]byte, 32)
34+
_, err := rand.Read(secret)
35+
if err != nil {
36+
return "", err
37+
}
38+
return base64.StdEncoding.EncodeToString(secret), nil
39+
}

diode-server/auth/manage_hydra.go

+161
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package auth
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log/slog"
7+
"net/http"
8+
"net/url"
9+
"strings"
10+
11+
hydra "github.com/ory/hydra-client-go/v2"
12+
)
13+
14+
type HydraClientManager struct {
15+
hydraAdmin *hydra.APIClient
16+
logger *slog.Logger
17+
}
18+
19+
func NewHydraClientManager(adminURL string, logger *slog.Logger) *HydraClientManager {
20+
hydraConfig := hydra.NewConfiguration()
21+
hydraConfig.Servers = []hydra.ServerConfiguration{
22+
{
23+
URL: adminURL,
24+
},
25+
}
26+
27+
return &HydraClientManager{
28+
hydraAdmin: hydra.NewAPIClient(hydraConfig),
29+
logger: logger,
30+
}
31+
}
32+
33+
func (h *HydraClientManager) CreateClient(ctx context.Context, clientInfo ClientInfo) (ClientInfo, error) {
34+
newClient := hydra.OAuth2Client{
35+
ClientId: &clientInfo.ClientID,
36+
Scope: &clientInfo.Scope,
37+
GrantTypes: []string{"client_credentials"},
38+
}
39+
if clientInfo.ClientSecret != "" {
40+
newClient.ClientSecret = &clientInfo.ClientSecret
41+
}
42+
43+
createdClient, response, err := h.hydraAdmin.OAuth2API.CreateOAuth2Client(ctx).OAuth2Client(newClient).Execute()
44+
if response.StatusCode == 409 {
45+
return ClientInfo{}, fmt.Errorf("failed to create client: client with id %s already exists", *newClient.ClientId)
46+
}
47+
if response.StatusCode == 400 {
48+
return ClientInfo{}, fmt.Errorf("failed to create client: invalid request")
49+
}
50+
if response.StatusCode != 201 {
51+
return ClientInfo{}, fmt.Errorf("failed to create client: status=%s", response.Status)
52+
}
53+
// these can be confusing and related to internal client failures, so handled after http status codes
54+
if err != nil {
55+
return ClientInfo{}, fmt.Errorf("failed to create client: %w", err)
56+
}
57+
58+
return clientInfoFromHydraClient(createdClient), nil
59+
}
60+
61+
func (h *HydraClientManager) DeleteClientByID(ctx context.Context, clientID string) error {
62+
response, err := h.hydraAdmin.OAuth2API.DeleteOAuth2Client(ctx, clientID).Execute()
63+
if response.StatusCode == 404 {
64+
return fmt.Errorf("client %s not found", clientID)
65+
}
66+
if response.StatusCode != 204 {
67+
return fmt.Errorf("failed to delete client from hydra: status=%s", response.Status)
68+
}
69+
// these can be confusing and related to internal client failures, so handled after http status codes
70+
if err != nil {
71+
return fmt.Errorf("failed to delete client from hydra: %w", err)
72+
}
73+
74+
return nil
75+
}
76+
77+
func (h *HydraClientManager) RetrieveClientByID(ctx context.Context, clientID string) (ClientInfo, error) {
78+
client, response, err := h.hydraAdmin.OAuth2API.GetOAuth2Client(ctx, clientID).Execute()
79+
if response.StatusCode == 404 {
80+
return ClientInfo{}, fmt.Errorf("client %s not found", clientID)
81+
}
82+
if response.StatusCode != 200 {
83+
return ClientInfo{}, fmt.Errorf("failed to retrieve client: status=%s", response.Status)
84+
}
85+
// these tend to be confusing and related to internal client failures, so handled after http status codes
86+
if err != nil {
87+
return ClientInfo{}, fmt.Errorf("failed to retrieve client: %w", err)
88+
}
89+
90+
return clientInfoFromHydraClient(client), nil
91+
}
92+
93+
func (h *HydraClientManager) RetrieveClients(ctx context.Context, q RetrieveClientsRequest) (RetrieveClientsResponse, error) {
94+
var out RetrieveClientsResponse
95+
96+
clients, response, err := h.hydraAdmin.OAuth2API.ListOAuth2Clients(ctx).PageToken(q.PageToken).Execute()
97+
if response.StatusCode != 200 {
98+
return out, fmt.Errorf("failed to retrieve clients: status=%s", response.Status)
99+
}
100+
// these can be confusing and related to internal client failures, so handled after http status codes
101+
if err != nil {
102+
return out, fmt.Errorf("failed to retrieve clients: %w", err)
103+
}
104+
105+
out.Clients = make([]ClientInfo, 0, len(clients))
106+
for _, client := range clients {
107+
out.Clients = append(out.Clients, clientInfoFromHydraClient(&client))
108+
}
109+
110+
out.NextPageToken = getHydraNextPageToken(response, h.logger)
111+
return out, nil
112+
}
113+
114+
func clientInfoFromHydraClient(client *hydra.OAuth2Client) ClientInfo {
115+
var clientInfo ClientInfo
116+
117+
if client == nil {
118+
return clientInfo
119+
}
120+
121+
if client.ClientId != nil {
122+
clientInfo.ClientID = *client.ClientId
123+
}
124+
if client.Scope != nil {
125+
clientInfo.Scope = *client.Scope
126+
}
127+
128+
return clientInfo
129+
}
130+
131+
func getHydraNextPageToken(response *http.Response, logger *slog.Logger) string {
132+
for _, linkHeader := range response.Header.Values("Link") {
133+
params := strings.Split(linkHeader, ";")
134+
link := params[0]
135+
params = params[1:]
136+
// search for rel="next"
137+
for _, param := range params {
138+
vs := strings.Split(param, "=")
139+
if len(vs) != 2 {
140+
continue
141+
}
142+
k, v := strings.TrimSpace(vs[0]), strings.TrimSpace(vs[1])
143+
if k == "rel" && (v == "next" || v == "\"next\"") {
144+
parsedURL, err := url.Parse(link)
145+
if err != nil {
146+
logger.Warn("failed to parse url in rel=next link", "error", err, "link", linkHeader)
147+
return ""
148+
}
149+
queryParams := parsedURL.Query()
150+
for key, values := range queryParams {
151+
if key == "page_token" {
152+
return values[0]
153+
}
154+
}
155+
logger.Warn("failed to find next page token in rel=next url", "link", linkHeader)
156+
return ""
157+
}
158+
}
159+
}
160+
return ""
161+
}

0 commit comments

Comments
 (0)