cl-openstack-client/keystone.lisp

428 lines
14 KiB
Common Lisp

(defpackage cl-keystone-client
(:use cl)
(:import-from #:cl-openstack-client
#:*http-stream*
#:assoc*
#:decode-resource
#:decode-resource-list
#:def-rest-method
#:error-code
#:error-message
#:handle-http-error
#:id
#:openstack-error
#:request-resource
#:resource
#:resource-authentication-headers
#:resource-connection
#:resource-error-class
#:resource-id
#:service-url)
(:import-from #:drakma
#:http-request)
(:import-from #:local-time
#:parse-timestring
#:timestamp>
#:now)
(:import-from #:cl-json
#:*json-input*
#:*json-identifier-name-to-lisp*
#:with-explicit-encoder
#:encode-json
#:encode-json-to-string)
(:import-from #:alexandria
#:alist-plist)
(:export connection-v2
authenticate
keystone-error
error-code
error-message
connection-username
connection-tenant-id
connection-tenant-name
connection-password
connection-url
connection-token-id
connection-token-expires
connection-token-issued-at
connection-token-valid-p
resource-id
resource-name
resource-connection
tenant-id
tenant-name
tenant-enabled
tenant-description
list-tenants
user-id
user-name
user-tenant
user-enabled
user-email
user-roles
add-user
get-user
delete-user
list-users
role-id
role-name
role-enabled
list-roles))
(in-package :cl-keystone-client)
(define-condition keystone-error (openstack-error) ())
(defclass connection ()
((username :initarg :username
:reader connection-username
:initform (error ":USERNAME is required when creating a connection."))
(tenant-id :initarg :tenant-id
:reader connection-tenant-id)
(tenant-name :initarg :tenant-name
:reader connection-tenant-name)
(password :initarg :password
:initform (error ":PASSWORD is required when creating a connection.")
:reader connection-password)
(endpoint :initarg :endpoint
:initform :public-url
:reader connection-endpoint)
(token :initarg :password)
(user)
(tenant)
(metadata)
(service-catalog :reader connection-service-catalog)
(url :initarg :url
:reader connection-url
:initform (error ":URL is required when creating a connection."))))
;; Add API compatability with the resource object
(defmethod resource-connection ((connection connection))
connection)
(defmethod resource-error-class ((resource connection))
'keystone-error)
(defmethod encode-json ((connection connection)
&optional (stream json:*json-output*))
"Write the JSON representation (Object) of the keystone CONNECTION
to STREAM (or to *JSON-OUTPUT*)."
(with-slots (username password) connection
(with-explicit-encoder
(encode-json
`(:object
:auth
(:object
:password-credentials
(:object
:username ,username
:password ,password)
,@(cond
((slot-boundp connection 'tenant-id)
(list :tenant-id (connection-tenant-id connection)))
((slot-boundp connection 'tenant-name)
(list :tenant-name (connection-tenant-name connection))))))
stream))))
(defclass connection-v2 (connection)
((version :initform 2 :reader connection-version)))
(defmethod headers-for ((connection connection-v2) &optional action)
(declare (ignore action))
nil)
(defun openstack-camel-case-to-lisp (camel-string)
"Convert camel case JSON keys to lisp symbol names. This function
handles keys with names like publicURL better and will convert keys
with underscores to hyphens."
(declare (string camel-string))
(let ((*print-pretty* nil))
(with-output-to-string (result)
(loop :for c :across camel-string
:with last-was-lowercase
:when (and last-was-lowercase
(upper-case-p c))
:do (princ "-" result)
:if (lower-case-p c)
:do (setf last-was-lowercase t)
:else
:do (setf last-was-lowercase nil)
:if (member c (list #\_))
:do (princ "-" result)
:else
:do (princ (char-upcase c) result)))))
(defun decode-json (&optional (stream *json-input*))
(let ((*json-identifier-name-to-lisp* #'openstack-camel-case-to-lisp))
(cl-json:decode-json stream)))
(defgeneric authenticate (connection)
(:documentation "Authenticate and retrieve a token."))
(defmethod authenticate ((connection connection-v2))
(with-slots (url token user service-catalog metadata tenant) connection
(multiple-value-bind (body status-code headers uri stream must-close reason-phrase)
(http-request (format nil "~a/v2.0/tokens" url)
:method :POST
:want-stream t
:stream *http-stream*
:content-type "application/json"
:content
(encode-json-to-string connection))
(declare (ignore must-close reason-phrase body))
(handle-http-error connection uri status-code headers stream)
(let ((access (assoc* :access (decode-json stream))))
(setf user (assoc* :user access))
(setf service-catalog (assoc* :service-catalog access))
(setf tenant (assoc* :tenant access))
(setf metadata (assoc* :metadata access))
(setf token (assoc* :token access)))))
connection)
(defgeneric connection-token-id (connection)
(:documentation "Retrieve token id for CONNECTION."))
(defmethod connection-token-id ((connection connection-v2))
(assoc* :id (slot-value connection 'token)))
(defgeneric connection-token-issued-at (connection)
(:documentation "Return the time the CONNECTION's token was issued
at."))
(defmethod connection-token-issued-at ((connection connection-v2))
(parse-timestring (assoc* :issued--at (slot-value connection 'token))))
(defgeneric connection-token-expires (connection)
(:documentation "Return the time when the CONNECTION's token will
expire."))
(defmethod connection-token-expires ((connection connection-v2))
(parse-timestring (assoc* :expires (slot-value connection 'token))))
(defgeneric connection-token-valid-p (connection)
(:documentation "Return T if the CONNECTION's token is still
valid."))
(defmethod connection-token-valid-p ((connection connection-v2))
(timestamp>
(connection-token-expires connection)
(now)))
(defmethod resource-authentication-headers ((resource connection-v2))
`(("x-auth-token" . ,(connection-token-id resource))))
;; Service catalog queries
(defun filter-endpoints (endpoints &key (type :public-url) region)
(loop :for endpoint :in endpoints
:when (or (not region)
(equal (assoc* :region endpoint) region))
:collect (assoc* type endpoint)))
(defmethod service-catalog-query ((connection connection-v2) service-type &key (type :public-url))
(loop :for service :in (connection-service-catalog connection)
:when (equal (assoc* :type service) service-type)
:append (filter-endpoints (assoc* :endpoints service)
:type type)))
(defmethod service-url ((connection connection-v2) &optional (service "identity"))
(car (service-catalog-query connection service
:type (connection-endpoint connection))))
(defclass resource-v2 (resource)
())
(defmethod resource-error-class ((resource resource-v2))
'keystone-error)
(defmethod resource-authentication-headers ((resource resource-v2))
(resource-authentication-headers (resource-connection resource)))
(defmethod service-url ((resource resource-v2) &optional (service "identity"))
(service-url (resource-connection resource) service))
(defclass named-resource-v2 (resource-v2)
((name :initarg :name :reader resource-name)))
(defmethod print-object ((resource named-resource-v2) stream)
(if (slot-boundp resource 'name)
(print-unreadable-object
(resource stream :type t :identity t)
(format stream "~a"
(cond
((and (slot-exists-p resource 'name)
(slot-boundp resource 'name))
(slot-value resource 'name))
((and (slot-exists-p resource 'id)
(slot-boundp resource 'id))
(slot-value resource 'id))
(t "UNKNOWN"))))
(print-unreadable-object (resource stream :type t :identity t))))
;; Tenants
(defclass tenant (named-resource-v2)
((id :initarg :id :reader tenant-id)
(name :initarg :name :reader tenant-name)
(enabled :initarg :enabled :reader tenant-enabled)
(description :initarg :description :reader tenant-description)))
(defclass tenant-v2 (tenant)
())
(defmethod encode-json ((tenant tenant-v2)
&optional (stream json:*json-output*))
"Write the JSON representation (Object) of the keystone CONNECTION
to STREAM (or to *JSON-OUTPUT*)."
(with-slots (id name enabled description) tenant
(with-explicit-encoder
(encode-json
`(:object
:tenant
(:object
:id ,id
:name ,name
:description ,description
:enabled ,enabled))
stream))))
(defmethod decode-resource ((type (eql 'tenant-v2)) (parent connection-v2) stream)
(loop :for tenant :in (assoc* :tenants (decode-json stream))
:collect (apply #'make-instance
type
:connection parent
(alist-plist tenant))))
(defgeneric list-tenants (resource))
(def-rest-method list-tenants ((connection connection-v2))
((:documentation "List all the tenants.")
(:uri "/tenants"))
(let ((json (request-resource connection :method :get)))
(decode-resource-list (assoc* :tenants json)
connection 'tenant-v2)))
;; Users
(defclass user (named-resource-v2)
((name :initarg :name :reader user-name)
(tenant-id :initarg :tenant-id :reader user-tenant)
(enabled :initarg :enabled :reader user-enabled)
(email :initarg :email :reader user-email)))
(defmethod user-id ((user user))
(resource-id user))
(defclass user-v2 (user)
())
;;; Make the connection behave like the current user.
(defmethod user-id ((connection connection-v2))
(assoc* :id (slot-value connection 'user)))
(defmethod user-name ((connection connection-v2))
(assoc* :name (slot-value connection 'user)))
(defgeneric list-users (resource))
(def-rest-method list-users ((tenant tenant-v2))
((:documentation "List all the users for tenant.")
(:uri "/tenants/{tenant}/users"))
(let ((json (request-resource tenant
:method :get)))
(decode-resource-list (assoc* :users json)
tenant
'user-v2)))
(def-rest-method list-users ((connection connection-v2))
((:documentation "List all users in keystone.")
(:uri "/users"))
(let ((json (request-resource connection :method :get)))
(decode-resource-list (assoc* :users json)
connection
'user-v2)))
(def-rest-method get-user (connection user)
((:documentation "Gets information for a specified user.")
(:uri "/users/{user}"))
(let ((json (request-resource connection :method :get)))
(decode-resource (assoc* :user json)
connection
'user-v2)))
(defgeneric add-user (connection &key name email enabled password))
(def-rest-method add-user ((connection connection-v2) &key name email (enabled t) password)
((:documentation "Add a user.")
(:uri "/users"))
(let ((json (request-resource
connection
:method :post
:content (with-output-to-string (stream)
(with-explicit-encoder
(encode-json
`(:object
:user
(:object
:name ,name
:email ,email
:enabled ,enabled
:password ,password))
stream))))))
(decode-resource (assoc* :user json)
connection
'user-v2)))
(defgeneric delete-user (resource user-or-user-id))
(def-rest-method delete-user ((connection connection-v2) user-or-user-id)
((:documentation "Delete a user.")
(:uri "/users/{user-or-user-id}"))
(request-resource connection :method :delete))
;; Roles
(defclass role (named-resource-v2)
((id :initarg :id :reader role-id)
(name :initarg :name :reader role-name)
(enabled :initarg :enabled :reader role-enabled)))
(defclass role-v2 (role)
())
(defgeneric add-tenant-users-role (tenant user role))
(def-rest-method add-tenant-users-role (tenant user role)
((:documentation "Adds a specified role to a user for a tenant.")
(:uri "/tenants/{tenant}/users/{user}/roles/OS-KSADM/{role}"))
(request-resource tenant :method :put))
(defgeneric delete-tenants-user-role (tenant user role))
(def-rest-method delete-tenants-user-role (tenant user role)
((:documentation "Deletes a specified role from a user on a tenant.")
(:uri "/tenants/{tenant}/users/{user}/roles/OS-KSADM/{role}"))
(request-resource tenant :method :delete))
(defgeneric list-roles (resource))
(def-rest-method list-roles ((connection connection-v2))
((:documentation "List roles.")
(:uri "/OS-KSADM/roles/"))
(let ((json (request-resource connection :method :get)))
(decode-resource-list (assoc* :roles json) connection 'role-v2)))
(def-rest-method list-roles ((user user-v2))
((:documentation "Lists global roles for a specified user. Excludes
tenant roles.")
(:uri "/users/{user}/roles"))
(let ((json (request-resource user :method :get)))
(decode-resource (assoc* :roles json) user 'role-v2)))