Skip to content

Commit

Permalink
feat(clickhousegrant): add new kind (#752)
Browse files Browse the repository at this point in the history
  • Loading branch information
rriski authored Jun 24, 2024
1 parent 16d77ca commit b30ccc9
Show file tree
Hide file tree
Showing 20 changed files with 2,338 additions and 27 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## [MAJOR.MINOR.PATCH] - YYYY-MM-DD

- Add kind: `ClickhouseGrant`

## v0.20.0 - 2024-06-05

- Add kind: `ServiceIntegrationEndpoint`
Expand Down
8 changes: 8 additions & 0 deletions PROJECT
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,14 @@ resources:
kind: ClickhouseRole
path: github.com/aiven/aiven-operator/api/v1alpha1
version: v1alpha1
- api:
crdVersion: v1
namespaced: true
controller: true
domain: aiven.io
kind: ClickhouseGrant
path: github.com/aiven/aiven-operator/api/v1alpha1
version: v1alpha1
- api:
crdVersion: v1
namespaced: true
Expand Down
316 changes: 316 additions & 0 deletions api/v1alpha1/clickhousegrant_types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,316 @@
// Copyright (c) 2024 Aiven, Helsinki, Finland. https://aiven.io/

package v1alpha1

import (
"context"
"fmt"
"strings"

avngen "github.com/aiven/go-client-codegen"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

"github.com/aiven/aiven-operator/utils"
chUtils "github.com/aiven/aiven-operator/utils/clickhouse"
)

// TODO: use oneOf in Grantee if https://github.com/kubernetes-sigs/controller-tools/issues/461 is resolved

// Grantee represents a user or a role to which privileges or roles are granted.
type Grantee struct {
User string `json:"user,omitempty"`
Role string `json:"role,omitempty"`
}

// PrivilegeGrant represents the privileges to be granted to users or roles.
// See https://clickhouse.com/docs/en/sql-reference/statements/grant#granting-privilege-syntax.
// +kubebuilder:validation:XValidation:rule="!has(self.columns) || (has(self.columns) && has(self.table))",message="`table` must be set if `columns` are set"
type PrivilegeGrant struct {
// List of grantees (users or roles) to grant the privilege to.
// +kubebuilder:validation:MinItems=1
Grantees []Grantee `json:"grantees"`
// The privileges to grant, i.e. `INSERT`, `SELECT`.
// See https://clickhouse.com/docs/en/sql-reference/statements/grant#assigning-role-syntax.
Privileges []string `json:"privileges"`
// The database that the grant refers to.
Database string `json:"database"`
// The tables that the grant refers to.
Table string `json:"table,omitempty"`
// The column that the grant refers to.
Columns []string `json:"columns,omitempty"`
// If true, then the grantee (user or role) get the permission to execute the `GRANT`` query.
// Users can grant privileges of the same scope they have and less.
// See https://clickhouse.com/docs/en/sql-reference/statements/grant#granting-privilege-syntax
WithGrantOption bool `json:"withGrantOption,omitempty"`
}

// RoleGrant represents the roles to be assigned to users or roles.
// See https://clickhouse.com/docs/en/sql-reference/statements/grant#assigning-role-syntax.
type RoleGrant struct {
// List of grantees (users or roles) to grant the privilege to.
// +kubebuilder:validation:MinItems=1
Grantees []Grantee `json:"grantees"`
// List of roles to grant to the grantees.
// +kubebuilder:validation:MinItems=1
Roles []string `json:"roles"`
// If true, the grant is executed with `ADMIN OPTION` privilege.
// See https://clickhouse.com/docs/en/sql-reference/statements/grant#admin-option.
WithAdminOption bool `json:"withAdminOption,omitempty"`
}

// ClickhouseGrantSpec defines the desired state of ClickhouseGrant
type ClickhouseGrantSpec struct {
ServiceDependant `json:",inline,omitempty"`

// Configuration to grant a privilege.
PrivilegeGrants []PrivilegeGrant `json:"privilegeGrants,omitempty"`
// Configuration to grant a role.
RoleGrants []RoleGrant `json:"roleGrants,omitempty"`
}

// ClickhouseGrantStatus defines the observed state of ClickhouseGrant
type ClickhouseGrantStatus struct {
Conditions []metav1.Condition `json:"conditions"`
}

//+kubebuilder:object:root=true
//+kubebuilder:subresource:status

// ClickhouseGrant is the Schema for the ClickhouseGrants API
// +kubebuilder:printcolumn:name="Project",type="string",JSONPath=".spec.project"
// +kubebuilder:printcolumn:name="Service Name",type="string",JSONPath=".spec.serviceName"
type ClickhouseGrant struct {
metav1.TypeMeta `json:",inline,omitempty"`
metav1.ObjectMeta `json:"metadata,omitempty"`

Spec ClickhouseGrantSpec `json:"spec,omitempty"`
Status ClickhouseGrantStatus `json:"status,omitempty"`
}

func (in ClickhouseGrantSpec) buildStatements(statementType chUtils.StatementType) []string {
stmts := make([]string, 0, len(in.PrivilegeGrants)+len(in.RoleGrants))
for _, g := range in.PrivilegeGrants {
stmts = append(stmts, buildStatement(statementType, g))
}
for _, g := range in.RoleGrants {
stmts = append(stmts, buildStatement(statementType, g))
}
return stmts
}

func (in ClickhouseGrantSpec) ExecuteStatements(ctx context.Context, avnGen avngen.Client, statementType chUtils.StatementType) (bool, error) {
statements := in.buildStatements(statementType)
for _, stmt := range statements {
_, err := chUtils.ExecuteClickHouseQuery(ctx, avnGen, in.Project, in.ServiceName, stmt)
if err != nil {
return false, err
}
}
return true, nil
}

func (in ClickhouseGrantSpec) CollectGrantees() []string {
allGrantees := []string{}
processGrantee := func(grantees []Grantee) {
for _, grantee := range grantees {
allGrantees = append(allGrantees, userOrRole(grantee))
}
}
for _, grant := range in.PrivilegeGrants {
processGrantee(grant.Grantees)
}
for _, grant := range in.RoleGrants {
processGrantee(grant.Grantees)
}

return utils.UniqueSliceElements(allGrantees)
}

func (in ClickhouseGrantSpec) CollectDatabases() []string {
allDatabases := []string{}
for _, grant := range in.PrivilegeGrants {
if grant.Database != "" {
allDatabases = append(allDatabases, grant.Database)
}
}
return utils.UniqueSliceElements(allDatabases)
}

func (in ClickhouseGrantSpec) CollectTables() []chUtils.DatabaseAndTable {
allTables := []chUtils.DatabaseAndTable{}
for _, grant := range in.PrivilegeGrants {
if grant.Table != "" {
allTables = append(allTables, chUtils.DatabaseAndTable{Database: grant.Database, Table: grant.Table})
}
}
return utils.UniqueSliceElements(allTables)
}

func (in *ClickhouseGrant) AuthSecretRef() *AuthSecretReference {
return in.Spec.AuthSecretRef
}

func (in *ClickhouseGrant) Conditions() *[]metav1.Condition {
return &in.Status.Conditions
}

func (in *ClickhouseGrant) NoSecret() bool {
return true
}

var _ AivenManagedObject = &ClickhouseGrant{}

//+kubebuilder:object:root=true

// ClickhouseGrantList contains a list of ClickhouseGrant
type ClickhouseGrantList struct {
metav1.TypeMeta `json:",inline,omitempty"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []ClickhouseGrant `json:"items,omitempty"`
}

func init() {
SchemeBuilder.Register(&ClickhouseGrant{}, &ClickhouseGrantList{})
}

// Takes a slice of PrivilegeGrant and returns a new slice
// where each grantee has its own PrivilegeGrant entry.
func FlattenPrivilegeGrants(grants []PrivilegeGrant) []PrivilegeGrant {
var flattened []PrivilegeGrant
for _, grant := range grants {
for _, grantee := range grant.Grantees {
newGrant := PrivilegeGrant{
Grantees: []Grantee{grantee},
Privileges: grant.Privileges,
Database: grant.Database,
Table: grant.Table,
Columns: grant.Columns,
WithGrantOption: grant.WithGrantOption,
}
flattened = append(flattened, newGrant)
}
}
return flattened
}

// Takes a slice of RoleGrant and returns a new slice
// where each grantee has its own RoleGrant entry.
func FlattenRoleGrants(grants []RoleGrant) []RoleGrant {
var flattened []RoleGrant
for _, grant := range grants {
for _, grantee := range grant.Grantees {
newGrant := RoleGrant{
Grantees: []Grantee{grantee},
Roles: grant.Roles,
WithAdminOption: grant.WithAdminOption,
}
flattened = append(flattened, newGrant)
}
}
return flattened
}

func (g PrivilegeGrant) ConstructParts(t chUtils.StatementType) (string, string, string) {
privilegesPart := constructPrivilegesPart(g)
granteesPart := constructGranteePart(g.Grantees)
options := ""
if g.WithGrantOption && t == chUtils.GRANT {
options = "WITH GRANT OPTION"
}
return privilegesPart, granteesPart, options
}

// Helper function to construct the privileges part of the statement
func constructPrivilegesPart(g PrivilegeGrant) string {
privileges := make([]string, 0, len(g.Privileges))
for _, privilege := range g.Privileges {
if (privilege == "SELECT" || privilege == "INSERT") && len(g.Columns) > 0 {
columnList := strings.Join(utils.MapSlice(g.Columns, escape), ", ")
privileges = append(privileges, fmt.Sprintf("%s(%s)", privilege, columnList))
} else {
privileges = append(privileges, privilege)
}
}
return strings.Join(privileges, ", ")
}

func (g RoleGrant) ConstructParts(t chUtils.StatementType) (string, string, string) {
rolesPart := strings.Join(g.Roles, ", ")
granteesPart := constructGranteePart(g.Grantees)
options := ""
if g.WithAdminOption && t == chUtils.GRANT {
options = "WITH ADMIN OPTION"
}
return rolesPart, granteesPart, options
}

func ExecuteGrant[T chUtils.Grant](ctx context.Context, avnGen avngen.Client, t chUtils.StatementType, grant T, projectName string, serviceName string) error {
stmt := buildStatement(t, grant)
_, err := chUtils.ExecuteClickHouseQuery(ctx, avnGen, projectName, serviceName, stmt)
if err != nil {
return err
}
return nil
}

// Generates a ClickHouse GRANT or REVOKE statement for privilege or role grants. See https://clickhouse.com/docs/en/sql-reference/statements/grant.
func buildStatement[T chUtils.Grant](t chUtils.StatementType, grant T) string {
mainPart, granteesPart, options := grant.ConstructParts(t)

fmtString := ""
switch t {
case chUtils.GRANT:
fmtString = "GRANT %s TO %s %s"
case chUtils.REVOKE:
fmtString = "REVOKE %s FROM %s %s"
}

// Adjust the format string based on the type of grant (PrivilegeGrant needs "ON %s" part)
if p, ok := any(grant).(PrivilegeGrant); ok {
// ON part is constructed only for PrivilegeGrant
onPart := constructOnPart(p)
if t == chUtils.GRANT {
fmtString = strings.Replace(fmtString, "TO", "ON %s TO", 1)
} else {
fmtString = strings.Replace(fmtString, "FROM", "ON %s FROM", 1)
}
return fmt.Sprintf(fmtString, mainPart, onPart, granteesPart, options)
}

return fmt.Sprintf(fmtString, mainPart, granteesPart, options)
}

// Helper function to construct the ON part of the statement
func constructOnPart(grant PrivilegeGrant) string {
switch {
case grant.Table != "":
return escape(grant.Database) + "." + escape(grant.Table)
default:
return escape(grant.Database) + ".*"
}
}

// Helper function to construct the TO part of the statement
func constructGranteePart(grantees []Grantee) string {
return strings.Join(utils.MapSlice(grantees, granteeToString), ", ")
}

// Converts a grantee to an escaped string
func granteeToString(grantee Grantee) string {
return escape(userOrRole(grantee))
}

func userOrRole(grantee Grantee) string {
if grantee.User != "" {
return grantee.User
}
return grantee.Role
}

// Escapes database identifiers like table or column names
func escape(identifier string) string {
// See https://github.com/ClickHouse/clickhouse-go/blob/8ad6ec6b95d8b0c96d00115bc2d69ff13083f94b/lib/column/column.go#L32
replacer := strings.NewReplacer("`", "\\`", "\\", "\\\\")
return "`" + replacer.Replace(identifier) + "`"
}
Loading

0 comments on commit b30ccc9

Please sign in to comment.