Skip to content

Commit

Permalink
AGENT-950: Implement Separate JWT Tokens for Different User Personas
Browse files Browse the repository at this point in the history
Create 3 seperate JWT tokens- AGENT_AUTH_TOKEN, USER_AUTH_TOKEN, WATCHER_AUTH_TOKEN
Update the claim to set 'sub' to identify the user persona
Pass different headers in the curl requests depending on the user persona, for example, Watcher-Authorization header for watcher user persona
  • Loading branch information
pawanpinjarkar committed Sep 18, 2024
1 parent 10951c5 commit fbeb34c
Show file tree
Hide file tree
Showing 14 changed files with 78 additions and 42 deletions.
9 changes: 5 additions & 4 deletions data/data/agent/files/usr/local/bin/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,21 @@
curl_assisted_service() {
local endpoint=$1
local method=${2:-GET}
local additional_options=("${@:3}") # Capture all arguments starting from the third one
local authz=${3:-}
local additional_options=("${@:4}") # Capture all arguments starting from the fourth one
local baseURL="${SERVICE_BASE_URL}api/assisted-install/v2"

case "${method}" in
"POST")
curl -s -S -X POST "${additional_options[@]}" "${baseURL}${endpoint}" \
-H "Authorization: ${AGENT_AUTH_TOKEN}" \
-H "Authorization: ${authz}" \
-H "accept: application/json" \
-H "Content-Type: application/json" \
;;
"GET")
curl -s -S -X GET "${additional_options[@]}" "${baseURL}${endpoint}" \
-H "Authorization: ${AGENT_AUTH_TOKEN}" \
-H "Authorization: ${authz}" \
-H "Accept: application/json"
;;
esac
}
}
2 changes: 1 addition & 1 deletion data/data/agent/files/usr/local/bin/start-agent.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ INFRA_ENV_ID=""
until [[ $INFRA_ENV_ID != "" && $INFRA_ENV_ID != "null" ]]; do
sleep 5
>&2 echo "Querying assisted-service for infra-env-id..."
INFRA_ENV_ID=$(curl_assisted_service "/infra-envs" GET | jq -r .[0].id)
INFRA_ENV_ID=$(curl_assisted_service "/infra-envs" GET $USER_AUTH_TOKEN | jq -r '.[0].id')
done
echo "Fetched infra-env-id and found: $INFRA_ENV_ID"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ cluster_id=""
while [[ "${cluster_id}" = "" ]]
do
# Get cluster id
cluster_id=$(curl_assisted_service "/clusters" GET | jq -r .[].id)
cluster_id=$(curl_assisted_service "/clusters" GET $USER_AUTH_TOKEN | jq -r .[].id)
if [[ "${cluster_id}" = "" ]]; then
sleep 2
fi
Expand All @@ -28,7 +28,7 @@ status_issue="90_start-install"
num_known_hosts() {
local known_hosts=0
local insufficient_hosts=0
host_status=$(curl_assisted_service "/infra-envs/${INFRA_ENV_ID}/hosts" GET | jq -r .[].status)
host_status=$(curl_assisted_service "/infra-envs/${INFRA_ENV_ID}/hosts" GET $USER_AUTH_TOKEN | jq -r .[].status)
if [[ -n ${host_status} ]]; then
for status in ${host_status}; do
if [[ "${status}" == "known" ]]; then
Expand Down Expand Up @@ -58,15 +58,15 @@ clear_issue "${status_issue}"
while [[ "${cluster_status}" != "installed" ]]
do
sleep 5
cluster_info="$(curl_assisted_service "/clusters" GET)"
cluster_info="$(curl_assisted_service "/clusters" GET $USER_AUTH_TOKEN)"
cluster_status=$(printf '%s' "${cluster_info}" | jq -r .[].status)
echo "Cluster status: ${cluster_status}" 1>&2
# Start the cluster install, if it transitions back to Ready due to a failure,
# then it will be restarted
case "${cluster_status}" in
"ready")
echo "Starting cluster installation..." 1>&2
res=$(curl_assisted_service "/clusters/${cluster_id}/actions/install" POST -w "%{http_code}" -o /dev/null)
res=$(curl_assisted_service "/clusters/${cluster_id}/actions/install" POST $USER_AUTH_TOKEN -w "%{http_code}" -o /dev/null)
if [[ $res = "202" ]]; then
printf '\nCluster installation started\n' 1>&2
fi
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ source "common.sh"

echo "Waiting for assisted-service to be ready"

until curl_assisted_service "/infra-envs" GET -o /dev/null --silent --fail; do
until curl_assisted_service "/infra-envs" GET $USER_AUTH_TOKEN -o /dev/null --silent --fail; do
printf '.'
sleep 5
done
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,6 @@ OPENSHIFT_INSTALL_RELEASE_IMAGE_MIRROR={{.ReleaseImageMirror}}
STORAGE=filesystem
INFRA_ENV_ID={{.InfraEnvID}}
EC_PUBLIC_KEY_PEM={{.PublicKeyPEM}}
AGENT_AUTH_TOKEN={{.Token}}
AGENT_AUTH_TOKEN={{.AgentAuthToken}}
USER_AUTH_TOKEN={{.UserAuthToken}}
WATCHER_AUTH_TOKEN={{.WatcherAuthToken}}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ EnvironmentFile=/usr/local/share/assisted-service/assisted-service.env
EnvironmentFile=/etc/assisted/add-nodes.env
ExecStartPre=/bin/rm -f %t/%n.ctr-id
ExecStartPre=/usr/local/bin/wait-for-assisted-service.sh
ExecStart=podman run --net host --cidfile=%t/%n.ctr-id --cgroups=no-conmon --log-driver=journald --rm --pod-id-file=%t/assisted-service-pod.pod-id --replace --name=agent-import-cluster -v /etc/assisted/clusterconfig:/clusterconfig -v /etc/assisted/manifests:/manifests -v /etc/assisted/extra-manifests:/extra-manifests {{ if .HaveMirrorConfig }}-v /etc/containers:/etc/containers{{ end }} {{.CaBundleMount}} --env SERVICE_BASE_URL --env OPENSHIFT_INSTALL_RELEASE_IMAGE_MIRROR --env CLUSTER_ID --env CLUSTER_NAME --env CLUSTER_API_VIP_DNS_NAME --env AGENT_AUTH_TOKEN $SERVICE_IMAGE /usr/local/bin/agent-installer-client importCluster
ExecStart=podman run --net host --cidfile=%t/%n.ctr-id --cgroups=no-conmon --log-driver=journald --rm --pod-id-file=%t/assisted-service-pod.pod-id --replace --name=agent-import-cluster -v /etc/assisted/clusterconfig:/clusterconfig -v /etc/assisted/manifests:/manifests -v /etc/assisted/extra-manifests:/extra-manifests {{ if .HaveMirrorConfig }}-v /etc/containers:/etc/containers{{ end }} {{.CaBundleMount}} --env SERVICE_BASE_URL --env OPENSHIFT_INSTALL_RELEASE_IMAGE_MIRROR --env CLUSTER_ID --env CLUSTER_NAME --env CLUSTER_API_VIP_DNS_NAME --env USER_AUTH_TOKEN $SERVICE_IMAGE /usr/local/bin/agent-installer-client importCluster
ExecStop=/usr/bin/podman stop --ignore --cidfile=%t/%n.ctr-id
ExecStopPost=/usr/bin/podman rm -f --ignore --cidfile=%t/%n.ctr-id

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ EnvironmentFile=/usr/local/share/assisted-service/agent-images.env
EnvironmentFile=/usr/local/share/assisted-service/assisted-service.env
ExecStartPre=/bin/rm -f %t/%n.ctr-id
ExecStartPre=/usr/local/bin/wait-for-assisted-service.sh
ExecStart=podman run --net host --cidfile=%t/%n.ctr-id --cgroups=no-conmon --log-driver=journald --rm --pod-id-file=%t/assisted-service-pod.pod-id --replace --name=agent-register-cluster -v /etc/assisted/manifests:/manifests -v /etc/assisted/extra-manifests:/extra-manifests {{ if .HaveMirrorConfig }}-v /etc/containers:/etc/containers{{ end }} {{.CaBundleMount}} --env SERVICE_BASE_URL --env OPENSHIFT_INSTALL_RELEASE_IMAGE_MIRROR --env AGENT_AUTH_TOKEN $SERVICE_IMAGE /usr/local/bin/agent-installer-client registerCluster
ExecStart=podman run --net host --cidfile=%t/%n.ctr-id --cgroups=no-conmon --log-driver=journald --rm --pod-id-file=%t/assisted-service-pod.pod-id --replace --name=agent-register-cluster -v /etc/assisted/manifests:/manifests -v /etc/assisted/extra-manifests:/extra-manifests {{ if .HaveMirrorConfig }}-v /etc/containers:/etc/containers{{ end }} {{.CaBundleMount}} --env SERVICE_BASE_URL --env OPENSHIFT_INSTALL_RELEASE_IMAGE_MIRROR --env USER_AUTH_TOKEN $SERVICE_IMAGE /usr/local/bin/agent-installer-client registerCluster
ExecStop=/usr/bin/podman stop --ignore --cidfile=%t/%n.ctr-id
ExecStopPost=/usr/bin/podman rm -f --ignore --cidfile=%t/%n.ctr-id

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ EnvironmentFile=/etc/assisted/rendezvous-host.env
EnvironmentFile=/usr/local/share/assisted-service/agent-images.env
EnvironmentFile=/usr/local/share/assisted-service/assisted-service.env
ExecStartPre=/bin/rm -f %t/%n.ctr-id
ExecStart=podman run --net host --cidfile=%t/%n.ctr-id --cgroups=no-conmon --log-driver=journald --rm --pod-id-file=%t/assisted-service-pod.pod-id --replace --name=agent-register-infraenv -v /etc/assisted/manifests:/manifests --env SERVICE_BASE_URL --env IMAGE_TYPE_ISO --env AGENT_AUTH_TOKEN $SERVICE_IMAGE /usr/local/bin/agent-installer-client registerInfraEnv
ExecStart=podman run --net host --cidfile=%t/%n.ctr-id --cgroups=no-conmon --log-driver=journald --rm --pod-id-file=%t/assisted-service-pod.pod-id --replace --name=agent-register-infraenv -v /etc/assisted/manifests:/manifests --env SERVICE_BASE_URL --env IMAGE_TYPE_ISO --env USER_AUTH_TOKEN $SERVICE_IMAGE /usr/local/bin/agent-installer-client registerInfraEnv
ExecStop=/usr/bin/podman stop --ignore --cidfile=%t/%n.ctr-id
ExecStopPost=/usr/bin/podman rm -f --ignore --cidfile=%t/%n.ctr-id

Expand Down
2 changes: 1 addition & 1 deletion data/data/agent/systemd/units/apply-host-config.service
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ EnvironmentFile=/usr/local/share/assisted-service/assisted-service.env
ExecStartPre=/bin/rm -f %t/%n.ctr-id
ExecStartPre=/bin/mkdir -p %t/agent-installer /etc/assisted/hostconfig
ExecStartPre=/usr/local/bin/wait-for-assisted-service.sh
ExecStart=podman run --net host --cidfile=%t/%n.ctr-id --cgroups=no-conmon --log-driver=journald --restart=on-failure:10 --pod-id-file=%t/assisted-service-pod.pod-id --replace --name=apply-host-config -v /etc/assisted/hostconfig:/etc/assisted/hostconfig -v %t/agent-installer:/var/run/agent-installer:z --env SERVICE_BASE_URL --env INFRA_ENV_ID --env WORKFLOW_TYPE --env AGENT_AUTH_TOKEN $SERVICE_IMAGE /usr/local/bin/agent-installer-client configure
ExecStart=podman run --net host --cidfile=%t/%n.ctr-id --cgroups=no-conmon --log-driver=journald --restart=on-failure:10 --pod-id-file=%t/assisted-service-pod.pod-id --replace --name=apply-host-config -v /etc/assisted/hostconfig:/etc/assisted/hostconfig -v %t/agent-installer:/var/run/agent-installer:z --env SERVICE_BASE_URL --env INFRA_ENV_ID --env WORKFLOW_TYPE --env USER_AUTH_TOKEN $SERVICE_IMAGE /usr/local/bin/agent-installer-client configure
ExecStop=/usr/bin/podman stop --ignore --cidfile=%t/%n.ctr-id
ExecStopPost=/usr/bin/podman rm -f --ignore --cidfile=%t/%n.ctr-id

Expand Down
8 changes: 4 additions & 4 deletions pkg/agent/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,25 +71,25 @@ func NewCluster(ctx context.Context, assetDir, rendezvousIP, kubeconfigPath, ssh
czero := &Cluster{}
capi := &clientSet{}

var authToken string
var watcherAuthToken string
var err error

switch workflowType {
case workflow.AgentWorkflowTypeInstall:
authToken, err = FindAuthTokenFromAssetStore(assetDir)
watcherAuthToken, err = FindAuthTokenFromAssetStore(assetDir)
if err != nil {
return nil, err
}
case workflow.AgentWorkflowTypeAddNodes:
authToken, err = gencrypto.GetAuthTokenFromCluster(ctx, kubeconfigPath)
watcherAuthToken, err = gencrypto.GetAuthTokenFromCluster(ctx, kubeconfigPath)
if err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("AgentWorkflowType value not supported: %s", workflowType)
}

restclient := NewNodeZeroRestClient(ctx, rendezvousIP, sshKey, authToken)
restclient := NewNodeZeroRestClient(ctx, rendezvousIP, sshKey, watcherAuthToken)

kubeclient, err := NewClusterKubeAPIClient(ctx, kubeconfigPath)
if err != nil {
Expand Down
6 changes: 3 additions & 3 deletions pkg/agent/rest.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ type NodeZeroRestClient struct {
}

// NewNodeZeroRestClient Initialize a new rest client to interact with the Agent Rest API on node zero.
func NewNodeZeroRestClient(ctx context.Context, rendezvousIP, sshKey, token string) *NodeZeroRestClient {
func NewNodeZeroRestClient(ctx context.Context, rendezvousIP, sshKey, watcherAuthToken string) *NodeZeroRestClient {
restClient := &NodeZeroRestClient{}

// Get SSH Keys which can be used to determine if Rest API failures are due to network connectivity issues
Expand All @@ -48,7 +48,7 @@ func NewNodeZeroRestClient(ctx context.Context, rendezvousIP, sshKey, token stri
Path: client.DefaultBasePath,
}

config.AuthInfo = gencrypto.UserAuthHeaderWriter(token)
config.AuthInfo = gencrypto.WatcherAuthHeaderWriter(watcherAuthToken) // this is used only by wait-for so set watcher auth alone

client := client.New(config)

Expand Down Expand Up @@ -137,7 +137,7 @@ func FindAuthTokenFromAssetStore(assetDir string) (string, error) {

var authToken string
if authConfig != nil {
authToken = authConfig.(*gencrypto.AuthConfig).AgentAuthToken
authToken = authConfig.(*gencrypto.AuthConfig).WatcherAuthToken
}

return authToken, nil
Expand Down
6 changes: 3 additions & 3 deletions pkg/asset/agent/gencrypto/auth_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import (
"github.com/pkg/errors"
)

// UserAuthHeaderWriter sets the JWT authorization token.
func UserAuthHeaderWriter(token string) runtime.ClientAuthInfoWriter {
// WatcherAuthHeaderWriter sets the JWT authorization token.
func WatcherAuthHeaderWriter(token string) runtime.ClientAuthInfoWriter {
return runtime.ClientAuthInfoWriterFunc(func(r runtime.ClientRequest, _ strfmt.Registry) error {
return r.SetHeaderParam("Authorization", token)
return r.SetHeaderParam("Watcher-Authorization", token)
})
}

Expand Down
45 changes: 36 additions & 9 deletions pkg/asset/agent/gencrypto/authconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const AuthType = "agent-installer-local"

// AuthConfig is an asset that generates ECDSA public/private keys, JWT token.
type AuthConfig struct {
PublicKey, AgentAuthToken, AgentAuthTokenExpiry, AuthType string
PublicKey, AgentAuthToken, UserAuthToken, WatcherAuthToken, AgentAuthTokenExpiry, AuthType string
}

var _ asset.Asset = (*AuthConfig)(nil)
Expand Down Expand Up @@ -69,23 +69,49 @@ func (a *AuthConfig) Generate(_ context.Context, dependencies asset.Parents) err
switch agentWorkflow.Workflow {
case workflow.AgentWorkflowTypeInstall:
// Auth tokens do not expire
token, err := generateToken(privateKey, nil)
agentAuthToken, err := generateToken("agentAuth", privateKey)
if err != nil {
return err
}
a.AgentAuthToken = token
a.AgentAuthToken = agentAuthToken

userAuthToken, err := generateToken("userAuth", privateKey)
if err != nil {
return err
}
a.UserAuthToken = userAuthToken

watcherAuthToken, err := generateToken("watcherAuth", privateKey)
if err != nil {
return err
}
a.WatcherAuthToken = watcherAuthToken

case workflow.AgentWorkflowTypeAddNodes:
addNodesConfig := &joiner.AddNodesConfig{}
dependencies.Get(addNodesConfig)

// Auth tokens expires after 48 hours
expiry := time.Now().UTC().Add(48 * time.Hour)
a.AgentAuthTokenExpiry = expiry.Format(time.RFC3339)
token, err := generateToken(privateKey, &expiry)

agentAuthToken, err := generateToken("agentAuth", privateKey, expiry)
if err != nil {
return err
}
a.AgentAuthToken = token
a.AgentAuthToken = agentAuthToken

userAuthToken, err := generateToken("userAuth", privateKey, expiry)
if err != nil {
return err
}
a.UserAuthToken = userAuthToken

watcherAuthToken, err := generateToken("watcherAuth", privateKey, expiry)
if err != nil {
return err
}
a.WatcherAuthToken = watcherAuthToken

err = a.createOrUpdateAuthTokenSecret(addNodesConfig.Params.Kubeconfig)
if err != nil {
Expand Down Expand Up @@ -147,10 +173,11 @@ func keyPairPEM() (string, string, error) {
}

// generateToken returns a JWT token based on the private key.
func generateToken(privateKkeyPem string, expiry *time.Time) (string, error) {
func generateToken(userPersona string, privateKeyPem string, expiry ...time.Time) (string, error) {
// Create the JWT claims
claims := jwt.MapClaims{}

claims := jwt.MapClaims{
"sub": userPersona,
}
// Set the expiry time if provided
if expiry != nil {
claims["exp"] = expiry.Unix()
Expand All @@ -159,7 +186,7 @@ func generateToken(privateKkeyPem string, expiry *time.Time) (string, error) {
// Create the token using the ES256 signing method and the claims
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)

priv, err := jwt.ParseECPrivateKeyFromPEM([]byte(privateKkeyPem))
priv, err := jwt.ParseECPrivateKeyFromPEM([]byte(privateKeyPem))
if err != nil {
return "", err
}
Expand Down
22 changes: 14 additions & 8 deletions pkg/asset/agent/image/ignition.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,9 @@ type agentTemplateData struct {
ConfigImageFiles string
ImageTypeISO string
PublicKeyPEM string
Token string
AgentAuthToken string
UserAuthToken string
WatcherAuthToken string
TokenExpiry string
AuthType string
CaBundleMount string
Expand Down Expand Up @@ -266,6 +268,8 @@ func (a *Ignition) Generate(_ context.Context, dependencies asset.Parents) error
authConfig.PublicKey,
authConfig.AuthType,
authConfig.AgentAuthToken,
authConfig.UserAuthToken,
authConfig.WatcherAuthToken,
authConfig.AgentAuthTokenExpiry,
caBundleMount,
len(registriesConfig.MirrorConfig) > 0,
Expand All @@ -281,7 +285,7 @@ func (a *Ignition) Generate(_ context.Context, dependencies asset.Parents) error

rendezvousHostFile := ignition.FileFromString(rendezvousHostEnvPath,
"root", 0644,
getRendezvousHostEnv(agentTemplateData.ServiceProtocol, a.RendezvousIP, authConfig.AgentAuthToken, agentWorkflow.Workflow))
getRendezvousHostEnv(agentTemplateData.ServiceProtocol, a.RendezvousIP, authConfig.AgentAuthToken, authConfig.UserAuthToken, agentWorkflow.Workflow))
config.Storage.Files = append(config.Storage.Files, rendezvousHostFile)

err = addBootstrapScripts(&config, agentManifests.ClusterImageSet.Spec.ReleaseImage)
Expand Down Expand Up @@ -382,7 +386,7 @@ func addBootstrapScripts(config *igntypes.Config, releaseImage string) (err erro
}

func getTemplateData(name, pullSecret, releaseImageList, releaseImage, releaseImageMirror, publicContainerRegistries,
imageTypeISO, infraEnvID, publicKey, authType, token, tokenExpiry, caBundleMount string,
imageTypeISO, infraEnvID, publicKey, authType, agentAuthToken, userAuthToken, watcherAuthToken, tokenExpiry, caBundleMount string,
haveMirrorConfig bool,
numMasters, numWorkers int,
osImage *models.OsImage,
Expand All @@ -404,13 +408,15 @@ func getTemplateData(name, pullSecret, releaseImageList, releaseImage, releaseIm
ImageTypeISO: imageTypeISO,
PublicKeyPEM: publicKey,
AuthType: authType,
Token: token,
AgentAuthToken: agentAuthToken,
UserAuthToken: userAuthToken,
WatcherAuthToken: watcherAuthToken,
TokenExpiry: tokenExpiry,
CaBundleMount: caBundleMount,
}
}

func getRendezvousHostEnv(serviceProtocol, nodeZeroIP, token string, workflowType workflow.AgentWorkflowType) string {
func getRendezvousHostEnv(serviceProtocol, nodeZeroIP, agentAuthtoken, userAuthToken string, workflowType workflow.AgentWorkflowType) string {
serviceBaseURL := url.URL{
Scheme: serviceProtocol,
Host: net.JoinHostPort(nodeZeroIP, "8090"),
Expand All @@ -421,7 +427,7 @@ func getRendezvousHostEnv(serviceProtocol, nodeZeroIP, token string, workflowTyp
Host: net.JoinHostPort(nodeZeroIP, "8888"),
Path: "/",
}
// AGENT_AUTH_TOKEN is required to authenticate API requests against agent-installer-local auth type.
// USER_AUTH_TOKEN is required to authenticate API requests against agent-installer-local auth type.
// PULL_SECRET_TOKEN contains the same value as AGENT_AUTH_TOKEN. The name PULL_SECRET_TOKEN is used in
// assisted-installer-agent, which is responsible for authenticating API requests related to agents.
// Historically, PULL_SECRET_TOKEN was used solely to store the pull secrets.
Expand All @@ -434,10 +440,10 @@ func getRendezvousHostEnv(serviceProtocol, nodeZeroIP, token string, workflowTyp
return fmt.Sprintf(`NODE_ZERO_IP=%s
SERVICE_BASE_URL=%s
IMAGE_SERVICE_BASE_URL=%s
AGENT_AUTH_TOKEN=%s
PULL_SECRET_TOKEN=%s
USER_AUTH_TOKEN=%s
WORKFLOW_TYPE=%s
`, nodeZeroIP, serviceBaseURL.String(), imageServiceBaseURL.String(), token, token, workflowType)
`, nodeZeroIP, serviceBaseURL.String(), imageServiceBaseURL.String(), agentAuthtoken, userAuthToken, workflowType)
}

func getAddNodesEnv(clusterInfo joiner.ClusterInfo, authTokenExpiry string) string {
Expand Down

0 comments on commit fbeb34c

Please sign in to comment.