diff --git a/CLAUDE.md b/CLAUDE.md index cfe1777..d4fca53 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,9 +33,9 @@ The application follows a command-line interface pattern using the Cobra library - Stores tokens securely in `~/.juliahub` with 0600 permissions 2. **API Integration**: - - **REST API**: Used for dataset operations (`/api/v1/datasets`, `/datasets/{uuid}/url/{version}`), registry operations (`/api/v1/registry/registries/descriptions`, `/api/v1/registry/config/registry/{name}`), package search/info primary path (`/packages/info`), token management (`/app/token/activelist`), user management (`/app/config/features/manage`), and landing page management (`/app/homepage` GET, `/app/config/homepage` POST/DELETE) - - **GraphQL API**: Used for projects, user info, and package search/info fallback (`/v1/graphql`) - - **Headers**: All GraphQL requests require `X-Hasura-Role: jhuser` header + - **REST API**: Used for dataset operations (`/api/v1/datasets`, `/datasets/{uuid}/url/{version}`), registry operations (`/api/v1/registry/registries/descriptions`, `/api/v1/registry/config/registry/{name}`, `/api/v1/registry/config/registrator/{name}`), package search/info primary path (`/packages/info`), token management (`/app/token/activelist`), user management (`/app/config/features/manage`), admin group management (`/app/config/groups`), and landing page management (`/app/homepage` GET, `/app/config/homepage` POST/DELETE) + - **GraphQL API**: Used for projects, user info, user list (`public_users`), group list, and package search/info fallback (`/v1/graphql`) + - **Headers**: All GraphQL requests require `Authorization: Bearer `, `X-Hasura-Role: jhuser`, and `X-Juliahub-Ensure-JS: true` - **Authentication**: Uses ID tokens (`token.IDToken`) for API calls 3. **Command Structure**: @@ -128,6 +128,8 @@ echo '{ "enabled": true, "display_apps": true, "owner": "", "sync_schedule": null, "download_providers": [{ "type": "cacheserver", "host": "https://pkg.juliahub.com", + "credential_key": "JC Auth Token", + "server_type": "", "github_credential_type": "", "api_host": "", "url": "", "user_name": "" "credential_key": "JC Auth Token" }] }' | go run . registry config add @@ -143,8 +145,11 @@ go run . project list go run . project list --user go run . project list --user john go run . user info +go run . user list +go run . group list go run . admin user list go run . admin user list --verbose +go run . admin group list ``` ### Test token operations @@ -372,7 +377,7 @@ jh run setup ## Development Notes - All ID fields in GraphQL responses should be typed correctly (string for UUIDs, int64 for user IDs) -- GraphQL queries are embedded as strings (consider external .gql files for complex queries) +- GraphQL queries are embedded at compile time using `go:embed` from `.gql` files (`userinfo.gql`, `users.gql`, `groups.gql`, `projects.gql`) - Error handling includes both HTTP and GraphQL error responses - Token refresh is automatic via `ensureValidToken()` - File uploads use multipart form data with proper content types @@ -388,8 +393,11 @@ jh run setup - Clone command supports `project` (without username) and defaults to the logged-in user's username - Folder naming conflicts are resolved with automatic numbering (project-1, project-2, etc.) - Credential helper follows Git protocol: responds only to JuliaHub URLs, ignores others +- `jh user list` uses GraphQL `public_users` query (via `users.gql`) and displays ` ()` per line +- `jh group list` uses GraphQL groups query (via `groups.gql`) and displays one group name per line - Admin user list command (`jh admin user list`) uses REST API endpoint `/app/config/features/manage` which requires appropriate permissions -- User list output is concise by default (Name and Email only); use `--verbose` flag for detailed information (UUID, groups, features) +- Admin group list command (`jh admin group list`) uses REST API endpoint `/app/config/groups` which requires appropriate permissions +- Admin user list output is compact by default (` ()`); use `--verbose` flag for detailed information (UUID, groups, features) - Registry list output is concise by default (UUID and Name only); use `--verbose` flag for detailed information (owner, creation date, package count, description) - Registry config command (`jh registry config `) uses REST API endpoint `/api/v1/registry/config/registry/{name}` (GET) and prints the full JSON response - Registry add/update commands (`jh registry config add` / `jh registry config update`) use REST API endpoint `/api/v1/registry/config/registry/{name}` (POST); the backend creates or updates based on whether the registry already exists diff --git a/README.md b/README.md index 5baa413..2396c0f 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ A command-line interface for interacting with JuliaHub, a platform for Julia com - **Project Management**: List and filter projects using GraphQL API - **Git Integration**: Clone, push, fetch, and pull with automatic JuliaHub authentication - **Julia Integration**: Install Julia and run with JuliaHub package server configuration -- **User Management**: Display user information and view profile details -- **Administrative Commands**: Manage users, tokens, and system resources (requires admin permissions) +- **User Management**: Display user information, list users and groups +- **Administrative Commands**: Manage users, groups, tokens, and system resources (requires admin permissions) ## Installation @@ -174,6 +174,9 @@ go build -o jh . - `jh registry config ` - Show the full JSON configuration for a registry - `jh registry config add` - Add a new registry (JSON payload via stdin or `--file`) - `jh registry config update` - Update an existing registry (same JSON schema as add, same flags) +- `jh registry permission list ` - List permissions for a registry +- `jh registry permission set --user|--group --privilege download|register` - Add or update a permission +- `jh registry permission remove --user|--group ` - Remove a permission ### Project Management (`jh project`) @@ -202,15 +205,23 @@ go build -o jh . ### User Information (`jh user`) -- `jh user info` - Show detailed user information +- `jh user info` - Show detailed information about the logged-in user +- `jh user list` - List all users (` ()` format, via GraphQL) + +### Group Information (`jh group`) + +- `jh group list` - List all groups (one per line, via GraphQL) ### Administrative Commands (`jh admin`) #### User Management - `jh admin user list` - List all users (requires appropriate permissions) - - Default: Shows only Name and Email + - Default: Shows ` ()` per line - `jh admin user list --verbose` - Show detailed user information including UUID, groups, and features +#### Group Management +- `jh admin group list` - List all groups via REST API (requires appropriate permissions) + #### Token Management - `jh admin token list` - List all tokens (requires appropriate permissions) - Default: Shows only Subject, Created By, and Expired status @@ -313,6 +324,8 @@ echo '{ "enabled": true, "display_apps": true, "owner": "", "sync_schedule": null, "download_providers": [{ "type": "cacheserver", "host": "https://pkg.juliahub.com", + "credential_key": "JC Auth Token", + "server_type": "", "github_credential_type": "", "api_host": "", "url": "", "user_name": "" "credential_key": "JC Auth Token" }] }' | jh registry config add @@ -337,6 +350,16 @@ jh project list --user jh project list --user alice ``` +### User and Group Operations + +```bash +# List users (GraphQL) +jh user list + +# List groups (GraphQL) +jh group list +``` + ### Administrative Operations ```bash @@ -346,6 +369,9 @@ jh admin user list # List users with detailed information jh admin user list --verbose +# List all groups via REST (requires admin permissions) +jh admin group list + # List all tokens (requires admin permissions) jh admin token list diff --git a/groups.gql b/groups.gql new file mode 100644 index 0000000..02394aa --- /dev/null +++ b/groups.gql @@ -0,0 +1,12 @@ +query Groups($name: String = "%", $limit: Int = 100) { + groups(where: { name: { _ilike: $name } }, limit: $limit) { + name + group_id + } + products { + name + display_name + id + compute_type_name + } +} diff --git a/main.go b/main.go index dac96d0..56c4c24 100644 --- a/main.go +++ b/main.go @@ -621,15 +621,26 @@ PROVIDER TYPES cacheserver — sync from a JuliaHub package cache: { - "type": "cacheserver", - "host": "", - "credential_key": "" + "type": "cacheserver", + "host": "", + "credential_key": "", + "server_type": "", + "github_credential_type": "", + "api_host": "", + "url": "", + "user_name": "" } bundle — local bundle (sets license_detect: false automatically): { - "type": "bundle", - "credential_key": "" + "type": "bundle", + "credential_key": "", + "server_type": "", + "github_credential_type": "", + "api_host": "", + "url": "", + "user_name": "", + "host": "" } genericserver — generic server with basic auth: @@ -893,6 +904,8 @@ var registryConfigAddCmd = &cobra.Command{ "enabled": true, "display_apps": true, "owner": "admin", "sync_schedule": null, "download_providers": [{ "type": "cacheserver", "host": "https://pkg.juliahub.com", + "credential_key": "JC Auth Token", + "server_type": "", "github_credential_type": "", "api_host": "", "url": "", "user_name": "" "credential_key": "JC Auth Token" }] }' | jh registry config add @@ -953,6 +966,8 @@ var registryConfigUpdateCmd = &cobra.Command{ "enabled": true, "display_apps": true, "owner": "admin", "sync_schedule": null, "download_providers": [{ "type": "cacheserver", "host": "https://pkg-new.juliahub.com", + "credential_key": "JC Auth Token", + "server_type": "", "github_credential_type": "", "api_host": "", "url": "", "user_name": "" "credential_key": "JC Auth Token" }] }' | jh registry config update @@ -1029,6 +1044,105 @@ Use --verbose flag to display comprehensive information including: }, } +var registryPermissionCmd = &cobra.Command{ + Use: "permission", + Short: "Manage registry permissions", + Long: `Manage access permissions for a Julia package registry. + +Permissions control which users and groups can access the registry. +Supported privilege levels: + download - read-only access to download packages + register - download access plus ability to register packages + +The registry owner and admins can always manage permissions regardless of settings.`, +} + +var registryPermissionListCmd = &cobra.Command{ + Use: "list ", + Short: "List permissions for a registry", + Example: " jh registry permission list MyRegistry\n jh registry permission list MyRegistry -s custom.juliahub.com", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + server, err := getServerFromFlagOrConfig(cmd) + if err != nil { + fmt.Printf("Failed to get server config: %v\n", err) + os.Exit(1) + } + if err := listRegistryPermissions(server, args[0]); err != nil { + fmt.Printf("Failed to list permissions: %v\n", err) + os.Exit(1) + } + }, +} + +var registryPermissionSetCmd = &cobra.Command{ + Use: "set ", + Short: "Add or update a permission for a user or group", + Long: `Add or update access permission for a user or group on a registry. + +Exactly one of --user or --group must be provided. +Privilege must be 'download' or 'register'.`, + Example: " jh registry permission set MyRegistry --user alice --privilege download\n jh registry permission set MyRegistry --group devs --privilege register", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + server, err := getServerFromFlagOrConfig(cmd) + if err != nil { + fmt.Printf("Failed to get server config: %v\n", err) + os.Exit(1) + } + user, _ := cmd.Flags().GetString("user") + group, _ := cmd.Flags().GetString("group") + privilege, _ := cmd.Flags().GetString("privilege") + if user == "" && group == "" { + fmt.Println("Error: one of --user or --group is required") + os.Exit(1) + } + if user != "" && group != "" { + fmt.Println("Error: only one of --user or --group may be specified") + os.Exit(1) + } + if privilege == "" { + fmt.Println("Error: --privilege is required (download or register)") + os.Exit(1) + } + if err := setRegistryPermission(server, args[0], user, group, privilege); err != nil { + fmt.Printf("Failed to set permission: %v\n", err) + os.Exit(1) + } + }, +} + +var registryPermissionRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove a permission for a user or group", + Long: `Remove access permission for a user or group from a registry. + +Exactly one of --user or --group must be provided.`, + Example: " jh registry permission remove MyRegistry --user alice\n jh registry permission remove MyRegistry --group devs", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + server, err := getServerFromFlagOrConfig(cmd) + if err != nil { + fmt.Printf("Failed to get server config: %v\n", err) + os.Exit(1) + } + user, _ := cmd.Flags().GetString("user") + group, _ := cmd.Flags().GetString("group") + if user == "" && group == "" { + fmt.Println("Error: one of --user or --group is required") + os.Exit(1) + } + if user != "" && group != "" { + fmt.Println("Error: only one of --user or --group may be specified") + os.Exit(1) + } + if err := removeRegistryPermission(server, args[0], user, group); err != nil { + fmt.Printf("Failed to remove permission: %v\n", err) + os.Exit(1) + } + }, +} + var projectCmd = &cobra.Command{ Use: "project", Short: "Project management commands", @@ -1121,6 +1235,45 @@ Shows comprehensive user information including: Uses GraphQL API to fetch detailed user information.`, } +var userListGQLCmd = &cobra.Command{ + Use: "list", + Short: "List all users", + Example: " jh user list\n jh user list -s custom.juliahub.com", + Run: func(cmd *cobra.Command, args []string) { + server, err := getServerFromFlagOrConfig(cmd) + if err != nil { + fmt.Printf("Failed to get server config: %v\n", err) + os.Exit(1) + } + if err := listUsersGQL(server); err != nil { + fmt.Printf("Failed to list users: %v\n", err) + os.Exit(1) + } + }, +} + +var groupCmd = &cobra.Command{ + Use: "group", + Short: "Group information commands", +} + +var groupListGQLCmd = &cobra.Command{ + Use: "list", + Short: "List all groups", + Example: " jh group list\n jh group list -s custom.juliahub.com", + Run: func(cmd *cobra.Command, args []string) { + server, err := getServerFromFlagOrConfig(cmd) + if err != nil { + fmt.Printf("Failed to get server config: %v\n", err) + os.Exit(1) + } + if err := listGroupsGQL(server); err != nil { + fmt.Printf("Failed to list groups: %v\n", err) + os.Exit(1) + } + }, +} + var userInfoCmd = &cobra.Command{ Use: "info", Short: "Show user information", @@ -1160,8 +1313,7 @@ Use --verbose flag to display comprehensive information including: - JuliaHub groups and site groups - Feature flags -This command uses the /app/config/features/manage endpoint which requires -appropriate permissions to view all users.`, +This command requires appropriate administrator permissions to view all users (including staged).`, Example: " jh admin user list\n jh admin user list --verbose", Run: func(cmd *cobra.Command, args []string) { server, err := getServerFromFlagOrConfig(cmd) @@ -1583,6 +1735,31 @@ Provides commands to list and manage API tokens across the JuliaHub instance. Note: These commands require appropriate administrative permissions.`, } +var adminGroupCmd = &cobra.Command{ + Use: "group", + Short: "Group management commands", + Long: `Administrative commands for managing groups on JuliaHub. + +Provides commands to list and manage groups across the JuliaHub instance.`, +} + +var groupListCmd = &cobra.Command{ + Use: "list", + Short: "List all groups", + Example: " jh admin group list\n jh admin group list -s custom.juliahub.com", + Run: func(cmd *cobra.Command, args []string) { + server, err := getServerFromFlagOrConfig(cmd) + if err != nil { + fmt.Printf("Failed to get server config: %v\n", err) + os.Exit(1) + } + if err := listGroups(server); err != nil { + fmt.Printf("Failed to list groups: %v\n", err) + os.Exit(1) + } + }, +} + var tokenListCmd = &cobra.Command{ Use: "list", Short: "List all tokens", @@ -1595,8 +1772,7 @@ Use --verbose flag to display comprehensive information including: - Expiration date (with estimate indicator) - Expiration status -This command uses the /app/token/activelist endpoint which requires -appropriate permissions to view all tokens.`, +This command requires appropriate permissions to view all tokens.`, Example: " jh admin token list\n jh admin token list --verbose", Run: func(cmd *cobra.Command, args []string) { server, err := getServerFromFlagOrConfig(cmd) @@ -1682,18 +1858,32 @@ func init() { registryConfigUpdateCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") registryConfigUpdateCmd.Flags().StringP("file", "f", "", "Path to JSON config file (reads from stdin if omitted)") registryConfigCmd.AddCommand(registryConfigAddCmd, registryConfigUpdateCmd) - registryCmd.AddCommand(registryListCmd, registryConfigCmd) + registryPermissionListCmd.Flags().StringP("server", "s", "", "JuliaHub server") + registryPermissionSetCmd.Flags().StringP("server", "s", "", "JuliaHub server") + registryPermissionSetCmd.Flags().String("user", "", "Username to set permission for") + registryPermissionSetCmd.Flags().String("group", "", "Group name to set permission for") + registryPermissionSetCmd.Flags().String("privilege", "", "Privilege level: download or register") + registryPermissionRemoveCmd.Flags().StringP("server", "s", "", "JuliaHub server") + registryPermissionRemoveCmd.Flags().String("user", "", "Username to remove permission for") + registryPermissionRemoveCmd.Flags().String("group", "", "Group name to remove permission for") + registryPermissionCmd.AddCommand(registryPermissionListCmd, registryPermissionSetCmd, registryPermissionRemoveCmd) + registryCmd.AddCommand(registryListCmd, registryConfigCmd, registryPermissionCmd) projectCmd.AddCommand(projectListCmd) - userCmd.AddCommand(userInfoCmd) + userListGQLCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") + userCmd.AddCommand(userInfoCmd, userListGQLCmd) + groupListGQLCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") + groupCmd.AddCommand(groupListGQLCmd) + groupListCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") + adminGroupCmd.AddCommand(groupListCmd) adminUserCmd.AddCommand(userListCmd) adminTokenCmd.AddCommand(tokenListCmd) adminLandingCmd.AddCommand(landingShowCmd, landingUpdateCmd, landingRemoveCmd) - adminCmd.AddCommand(adminUserCmd, adminTokenCmd, adminLandingCmd) + adminCmd.AddCommand(adminUserCmd, adminTokenCmd, adminGroupCmd, adminLandingCmd) juliaCmd.AddCommand(juliaInstallCmd) runCmd.AddCommand(runSetupCmd) gitCredentialCmd.AddCommand(gitCredentialHelperCmd, gitCredentialGetCmd, gitCredentialStoreCmd, gitCredentialEraseCmd, gitCredentialSetupCmd) - rootCmd.AddCommand(authCmd, jobCmd, datasetCmd, projectCmd, packageCmd, registryCmd, userCmd, adminCmd, juliaCmd, cloneCmd, pushCmd, fetchCmd, pullCmd, runCmd, gitCredentialCmd, updateCmd) + rootCmd.AddCommand(authCmd, jobCmd, datasetCmd, projectCmd, packageCmd, registryCmd, userCmd, groupCmd, adminCmd, juliaCmd, cloneCmd, pushCmd, fetchCmd, pullCmd, runCmd, gitCredentialCmd, updateCmd) } func main() { diff --git a/projects.go b/projects.go index 13fbcd6..e73c6a0 100644 --- a/projects.go +++ b/projects.go @@ -2,6 +2,7 @@ package main import ( "bytes" + _ "embed" "encoding/json" "fmt" "io" @@ -10,6 +11,9 @@ import ( "time" ) +//go:embed projects.gql +var projectsQuery string + type Project struct { ID string `json:"id"` ProjectID string `json:"project_id"` @@ -128,107 +132,7 @@ func listProjects(server string, userFilter string, userFilterProvided bool) err return fmt.Errorf("failed to get user info: %w", err) } - // Read the GraphQL query from projects.gql - query := `query Projects( - $limit: Int - $offset: Int - $orderBy: [projects_order_by!] - $ownerId: bigint - $filter: projects_bool_exp - ) { - aggregate: projects_aggregate(where: $filter) { - aggregate { - count - } - } - projects(limit: $limit, offset: $offset, order_by: $orderBy, where: $filter) { - id: project_id - project_id - name - owner { - username - name - } - created_at - product_id - finished - is_archived - instance_default_role - deployable - project_deployments_aggregate { - aggregate { - count - } - } - running_deployments: project_deployments_aggregate( - where: { - status: { _eq: "JobQueued" } - job: { status: { _eq: "Running" } } - } - ) { - aggregate { - count - } - } - pending_deployments: project_deployments_aggregate( - where: { - status: { _eq: "JobQueued" } - job: { status: { _in: ["SubmitInitialized", "Submitted", "Pending"] } } - } - ) { - aggregate { - count - } - } - resources(order_by: [{ sorting_order: asc_nulls_last }]) { - sorting_order - instance_default_role - giturl - name - resource_id - resource_type - } - product { - id - displayName: display_name - name - } - visibility - description - users: groups(where: { group_id: { _is_null: true } }) { - user { - name - } - id - assigned_role - } - groups(where: { group_id: { _is_null: false } }) { - group { - name - group_id - } - id: group_id - group_id - project_id - assigned_role - } - tags - userRole: access_control_users_aggregate( - where: { user_id: { _eq: $ownerId } } - ) { - aggregate { - max { - assigned_role - } - } - } - is_simple_mode - projects_current_editor_user_id { - name - id - } - } - }` + query := projectsQuery // Create GraphQL request graphqlReq := GraphQLRequest{ diff --git a/registries.go b/registries.go index 5eaf495..c0eef88 100644 --- a/registries.go +++ b/registries.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "os" + "strings" "time" ) @@ -256,6 +257,282 @@ type saveStatusResponse struct { } `json:"result"` } +// RegistryPermission represents a single user or group permission entry for a registry. +type RegistryPermission struct { + User *string `json:"user"` + Realm *string `json:"realm"` + Group *string `json:"group"` + Privilege string `json:"privilege"` +} + +func resolveRegistryUUID(server, idToken, nameOrUUID string) (string, error) { + body, err := apiGet(fmt.Sprintf("https://%s/api/v1/registry/registries/descriptions", server), idToken) + if err != nil { + return "", err + } + var registries []Registry + if err := json.Unmarshal(body, ®istries); err != nil { + return "", fmt.Errorf("failed to parse registries: %w", err) + } + for _, r := range registries { + if r.UUID == nameOrUUID || r.Name == nameOrUUID { + return r.UUID, nil + } + } + return "", fmt.Errorf("registry %q not found", nameOrUUID) +} + +func getRegistryPermissions(server, idToken, uuid string) ([]RegistryPermission, error) { + body, err := apiGet(fmt.Sprintf("https://%s/api/v1/registry/config/registry/%s/sharing", server, uuid), idToken) + if err != nil { + return nil, err + } + var perms []RegistryPermission + if err := json.Unmarshal(body, &perms); err != nil { + return nil, fmt.Errorf("failed to parse permissions: %w", err) + } + return perms, nil +} + +func putRegistryPermissions(server, idToken, uuid string, perms []RegistryPermission) error { + data, err := json.Marshal(perms) + if err != nil { + return fmt.Errorf("failed to marshal permissions: %w", err) + } + client := &http.Client{Timeout: 30 * time.Second} + req, err := http.NewRequest("POST", fmt.Sprintf("https://%s/api/v1/registry/config/registry/%s/sharing", server, uuid), bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", idToken)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("API request failed (status %d): %s", resp.StatusCode, string(body)) + } + return nil +} + +func listRegistryPermissions(server, name string) error { + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + uuid, err := resolveRegistryUUID(server, token.IDToken, name) + if err != nil { + return err + } + perms, err := getRegistryPermissions(server, token.IDToken, uuid) + if err != nil { + return err + } + if len(perms) == 0 { + fmt.Println("No permissions set (registry is accessible to all users)") + return nil + } + fmt.Printf("%-30s %-8s %s\n", "User/Group", "Type", "Privilege") + fmt.Printf("%s\n", strings.Repeat("-", 52)) + for _, p := range perms { + subject, kind := "", "" + if p.User != nil { + subject, kind = *p.User, "user" + } else if p.Group != nil { + subject, kind = *p.Group, "group" + } + fmt.Printf("%-30s %-8s %s\n", subject, kind, p.Privilege) + } + return nil +} + +func setRegistryPermission(server, name, user, group, privilege string) error { + if privilege != "download" && privilege != "register" { + return fmt.Errorf("privilege must be 'download' or 'register'") + } + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + uuid, err := resolveRegistryUUID(server, token.IDToken, name) + if err != nil { + return err + } + perms, err := getRegistryPermissions(server, token.IDToken, uuid) + if err != nil { + return err + } + found := false + for i, p := range perms { + if user != "" && p.User != nil && *p.User == user { + perms[i].Privilege = privilege + found = true + break + } + if group != "" && p.Group != nil && *p.Group == group { + perms[i].Privilege = privilege + found = true + break + } + } + if !found { + newPerm := RegistryPermission{Privilege: privilege} + if user != "" { + newPerm.User = &user + } else { + realm := "site" + newPerm.Group = &group + newPerm.Realm = &realm + } + perms = append(perms, newPerm) + } + if err := putRegistryPermissions(server, token.IDToken, uuid, perms); err != nil { + return err + } + subject := user + if group != "" { + subject = group + } + action := "updated" + if !found { + action = "added" + } + fmt.Printf("Permission %s: %s now has '%s' access\n", action, subject, privilege) + return nil +} + +func removeRegistryPermission(server, name, user, group string) error { + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + uuid, err := resolveRegistryUUID(server, token.IDToken, name) + if err != nil { + return err + } + perms, err := getRegistryPermissions(server, token.IDToken, uuid) + if err != nil { + return err + } + original := len(perms) + filtered := perms[:0] + for _, p := range perms { + keep := true + if user != "" && p.User != nil && *p.User == user { + keep = false + } + if group != "" && p.Group != nil && *p.Group == group { + keep = false + } + if keep { + filtered = append(filtered, p) + } + } + if len(filtered) == original { + subject := user + if group != "" { + subject = group + } + return fmt.Errorf("%q has no permission on this registry", subject) + } + if err := putRegistryPermissions(server, token.IDToken, uuid, filtered); err != nil { + return err + } + subject := user + if group != "" { + subject = group + } + fmt.Printf("Permission removed: %s\n", subject) + return nil +} + +func getRegistrator(server, name string) error { + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + + body, err := apiGet(fmt.Sprintf("https://%s/api/v1/registry/config/registrator/%s", server, name), token.IDToken) + if err != nil { + return err + } + + var pretty bytes.Buffer + if err := json.Indent(&pretty, body, "", " "); err != nil { + fmt.Println(string(body)) + return nil + } + fmt.Println(pretty.String()) + return nil +} + +func setRegistrator(server, name, filePath string) error { + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + + var data []byte + if filePath != "" { + data, err = os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("failed to read file %q: %w", filePath, err) + } + } else { + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) != 0 { + return fmt.Errorf("no JSON payload provided — pipe JSON via stdin or use --file") + } + data, err = io.ReadAll(os.Stdin) + if err != nil { + return fmt.Errorf("failed to read stdin: %w", err) + } + } + + var payload map[string]interface{} + if err := json.Unmarshal(data, &payload); err != nil { + return fmt.Errorf("failed to parse JSON: %w", err) + } + + if enabled, _ := payload["enabled"].(bool); enabled { + if email, _ := payload["email"].(string); email == "" { + return fmt.Errorf("\"email\" is required when registrator is enabled") + } + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + apiURL := fmt.Sprintf("https://%s/api/v1/registry/config/registrator/%s", server, name) + client := &http.Client{Timeout: 30 * time.Second} + req, err := http.NewRequest("POST", apiURL, bytes.NewReader(payloadBytes)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.IDToken)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("API request failed (status %d): %s", resp.StatusCode, string(body)) + } + + fmt.Println("Registrator updated successfully") + return nil +} + func pollRegistrySaveStatus(server, idToken, registryName, operation string) error { apiURL := fmt.Sprintf("https://%s/api/v1/registry/config/registry/%s/savestatus", server, registryName) client := &http.Client{Timeout: 30 * time.Second} diff --git a/user.go b/user.go index 03b2e23..8568bfb 100644 --- a/user.go +++ b/user.go @@ -2,6 +2,7 @@ package main import ( "bytes" + _ "embed" "encoding/json" "fmt" "io" @@ -9,6 +10,15 @@ import ( "time" ) +//go:embed userinfo.gql +var userinfoQuery string + +//go:embed users.gql +var usersQuery string + +//go:embed groups.gql +var groupsQuery string + type UserInfo struct { ID int64 `json:"id"` Name string `json:"name"` @@ -66,34 +76,7 @@ func getUserInfo(server string) (*UserInfo, error) { return nil, fmt.Errorf("authentication required: %w", err) } - // GraphQL query from userinfo.gql - query := `query UserInfo { - users(limit: 1) { - id - name - firstname - emails { - email - } - groups: user_groups { - id: group_id - group { - name - group_id - } - } - username - roles { - role { - description - id - name - } - } - accepted_tos - survey_submitted_time - } -}` + query := userinfoQuery // Create GraphQL request graphqlReq := UserInfoRequest{ @@ -213,6 +196,186 @@ type ManageUsersResponse struct { Features json.RawMessage `json:"features"` } +type GroupsGQLResponse struct { + Data struct { + Groups []struct { + Name string `json:"name"` + GroupID int64 `json:"group_id"` + } `json:"groups"` + Products []struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + ID int64 `json:"id"` + ComputeTypeName string `json:"compute_type_name"` + } `json:"products"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + +type AdminGroup struct { + Name string `json:"name"` + ID int64 `json:"id"` +} + +func listGroups(server string) error { + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + + url := fmt.Sprintf("https://%s/app/config/groups", server) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.IDToken)) + req.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to fetch groups: %w", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("request failed (status %d): %s", resp.StatusCode, string(body)) + } + + var groups []AdminGroup + if err := json.Unmarshal(body, &groups); err != nil { + return fmt.Errorf("failed to parse groups: %w", err) + } + if len(groups) == 0 { + fmt.Println("No groups found") + return nil + } + for _, g := range groups { + fmt.Println(g.Name) + } + return nil +} + +func listGroupsGQL(server string) error { + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + + gqlReq := UserInfoRequest{ + OperationName: "Groups", + Query: groupsQuery, + Variables: map[string]interface{}{"limit": 500}, + } + jsonData, err := json.Marshal(gqlReq) + if err != nil { + return fmt.Errorf("failed to marshal groups request: %w", err) + } + req, err := http.NewRequest("POST", fmt.Sprintf("https://%s/v1/graphql", server), bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.IDToken)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Hasura-Role", "jhuser") + req.Header.Set("X-Juliahub-Ensure-JS", "true") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to fetch groups: %w", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + var groupsResp GroupsGQLResponse + if err := json.Unmarshal(body, &groupsResp); err != nil { + return fmt.Errorf("failed to parse groups: %w", err) + } + if len(groupsResp.Errors) > 0 { + return fmt.Errorf("GraphQL errors: %v", groupsResp.Errors) + } + if len(groupsResp.Data.Groups) == 0 { + fmt.Println("No groups found") + return nil + } + for _, g := range groupsResp.Data.Groups { + fmt.Println(g.Name) + } + return nil +} + +type UsersGQLResponse struct { + Data struct { + Users []struct { + Username string `json:"username"` + ID int64 `json:"id"` + Name *string `json:"name"` + } `json:"users"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + +func listUsersGQL(server string) error { + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + + gqlReq := UserInfoRequest{ + OperationName: "Users", + Query: usersQuery, + Variables: map[string]interface{}{"limit": 500}, + } + jsonData, err := json.Marshal(gqlReq) + if err != nil { + return fmt.Errorf("failed to marshal users request: %w", err) + } + req, err := http.NewRequest("POST", fmt.Sprintf("https://%s/v1/graphql", server), bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.IDToken)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Hasura-Role", "jhuser") + req.Header.Set("X-Juliahub-Ensure-JS", "true") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to fetch users: %w", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + var usersResp UsersGQLResponse + if err := json.Unmarshal(body, &usersResp); err != nil { + return fmt.Errorf("failed to parse users: %w", err) + } + if len(usersResp.Errors) > 0 { + return fmt.Errorf("GraphQL errors: %v", usersResp.Errors) + } + if len(usersResp.Data.Users) == 0 { + fmt.Println("No users found") + return nil + } + for _, u := range usersResp.Data.Users { + name := u.Username + if u.Name != nil && *u.Name != "" { + name = *u.Name + } + fmt.Printf("%s (%s)\n", name, u.Username) + } + return nil +} + func listUsers(server string, verbose bool) error { token, err := ensureValidToken() if err != nil { @@ -260,10 +423,8 @@ func listUsers(server string, verbose bool) error { } } - // Display users - fmt.Printf("Users (%d total):\n\n", len(response.Users)) - if verbose { + fmt.Printf("Users (%d total):\n\n", len(response.Users)) // Verbose mode: show all details for _, user := range response.Users { fmt.Printf("UUID: %s\n", user.UUID) @@ -283,15 +444,12 @@ func listUsers(server string, verbose bool) error { fmt.Println() } } else { - // Default mode: show only Name and Email for _, user := range response.Users { - if user.Name != nil { - fmt.Printf("Name: %s\n", *user.Name) - } else { - fmt.Printf("Name: (not set)\n") + name := user.Email + if user.Name != nil && *user.Name != "" { + name = *user.Name } - fmt.Printf("Email: %s\n", user.Email) - fmt.Println() + fmt.Printf("%s (%s)\n", name, user.Email) } } diff --git a/users.gql b/users.gql new file mode 100644 index 0000000..f10dc6f --- /dev/null +++ b/users.gql @@ -0,0 +1,12 @@ +query Users($name: String = "%", $limit: Int = 100) { + users: public_users( + limit: $limit + where: { + _or: [{ username: { _ilike: $name } }, { name: { _ilike: $name } }] + } + ) { + username + id + name + } +}