Skip to content

Commit 11f0deb

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

File tree

10 files changed

+659
-3
lines changed

10 files changed

+659
-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

+98
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,104 @@ 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+
** NOTE: The client secret is only displayed once and cannot be retrieved later.
130+
Store credentials in a secure location. If you lose them, you will need to
131+
destroy and regenerate the client.
132+
{
133+
"client_id": "my-agent-001",
134+
"scope": "diode:ingest",
135+
"client_secret": "a_new_generated_secret"
136+
}
137+
```
138+
139+
**Create a client with a supplied secret** (in this case the secret is not returned)
140+
```bash
141+
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"
142+
143+
client created successfully.
144+
{
145+
"client_id": "my-agent-002",
146+
"scope": "diode:ingest"
147+
}
148+
```
149+
150+
**Retrieve info of an existing client**
151+
```bash
152+
docker compose run --rm --no-deps diode-auth auth_manage get-client --client-id my-agent-001
153+
154+
{
155+
"client_id": "my-agent-001",
156+
"scope": "diode:ingest"
157+
}
158+
```
159+
160+
**List existing clients**
161+
```bash
162+
docker compose run --rm --no-deps diode-auth auth_manage list-clients
163+
164+
[
165+
{
166+
"client_id": "diode-ingest",
167+
"scope": "diode:ingest"
168+
},
169+
{
170+
"client_id": "diode-to-netbox",
171+
"scope": "netbox:read netbox:write"
172+
},
173+
{
174+
"client_id": "my-agent-001",
175+
"scope": "diode:ingest"
176+
},
177+
{
178+
"client_id": "my-agent-002",
179+
"scope": "diode:ingest"
180+
},
181+
{
182+
"client_id": "netbox-to-diode",
183+
"scope": "diode:read diode:write"
184+
}
185+
]
186+
```
187+
188+
**Delete an existing client**
189+
```bash
190+
docker compose run --rm --no-deps diode-auth auth_manage delete-client --client-id my-agent-002
191+
192+
client my-agent-002 deleted successfully
193+
```
194+
195+
**List auth utility subcommands**
196+
```bash
197+
docker compose run --rm --no-deps diode-auth auth_manage
198+
199+
usage: auth_manage <subcommand>
200+
subcommands: list-clients get-client delete-client create-client
201+
```
202+
203+
**Additional help on a subcommand**
204+
```bash
205+
docker compose run --rm --no-deps diode-auth auth_manage create-client --help
206+
207+
Usage of create-client:
208+
-allow-ingest
209+
include scopes that allow the client to ingest data
210+
-client-id string
211+
client id
212+
-client-secret string
213+
client secret [generated if not provided]
214+
-scope string
215+
space separated list of scopes to allow
216+
```
217+
---
218+
121219
## License
122220
123221
Distributed under the NetBox Limited Use License 1.0.

diode-server/auth/manage.go

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

diode-server/auth/manage_hydra.go

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

0 commit comments

Comments
 (0)