Index: wp-admin/includes/ajax-actions.php
===================================================================
--- wp-admin/includes/ajax-actions.php	(revision 23028)
+++ wp-admin/includes/ajax-actions.php	(working copy)
@@ -1812,9 +1812,15 @@
 	if ( ! $id = absint( $_REQUEST['id'] ) )
 		wp_send_json_error();
 
-	if ( ! current_user_can( 'read_post', $id ) )
+	if ( ! $post = get_post( $id ) )
 		wp_send_json_error();
 
+	if ( 'attachment' != $post->post_type )
+		wp_send_json_error();
+
+	if ( ! current_user_can( 'upload_files' ) )
+		wp_send_json_error();
+
 	if ( ! $attachment = wp_prepare_attachment_for_js( $id ) )
 		wp_send_json_error();
 
@@ -1827,6 +1833,9 @@
  * @since 3.5.0
  */
 function wp_ajax_query_attachments() {
+	if ( ! current_user_can( 'upload_files' ) )
+		wp_send_json_error();
+
 	$query = isset( $_REQUEST['query'] ) ? (array) $_REQUEST['query'] : array();
 	$query = array_intersect_key( $query, array_flip( array(
 		's', 'order', 'orderby', 'posts_per_page', 'paged', 'post_mime_type',
@@ -1988,15 +1997,14 @@
 	if ( ! $post = get_post( $id ) )
 		wp_send_json_error();
 
-	if ( ! current_user_can( 'edit_post', $id ) )
-		wp_send_json_error();
-
 	if ( 'attachment' != $post->post_type )
 		wp_send_json_error();
 
-	// If this attachment is unattached, attach it. Primarily a back compat thing.
-	if ( 0 == $post->post_parent && $insert_into_post_id = intval( $_POST['post_id'] ) ) {
-		wp_update_post( array( 'ID' => $id, 'post_parent' => $insert_into_post_id ) );
+	if ( current_user_can( 'edit_post', $id ) ) {
+		// If this attachment is unattached, attach it. Primarily a back compat thing.
+		if ( 0 == $post->post_parent && $insert_into_post_id = intval( $_POST['post_id'] ) ) {
+			wp_update_post( array( 'ID' => $id, 'post_parent' => $insert_into_post_id ) );
+		}
 	}
 
 	$rel = $url = '';
Index: wp-includes/css/media-views.css
===================================================================
--- wp-includes/css/media-views.css	(revision 23028)
+++ wp-includes/css/media-views.css	(working copy)
@@ -61,6 +61,13 @@
 	border-color: #dfdfdf;
 }
 
+.media-frame input:disabled,
+.media-frame textarea:disabled,
+.media-frame input[readonly],
+.media-frame textarea[readonly] {
+	background-color: #eee;
+}
+
 .media-frame input[type="search"] {
 	-webkit-appearance: textfield;
 }
@@ -1230,13 +1237,15 @@
 	margin: 0 5px 0;
 }
 
-.media-sidebar .settings-save-status .saved {
+.media-sidebar .settings-save-status .saved,
+.media-sidebar .settings-save-status .error {
 	float: right;
 	display: none;
 }
 
 .media-sidebar .save-waiting .settings-save-status .spinner,
-.media-sidebar .save-complete .settings-save-status .saved {
+.media-sidebar .save-complete .settings-save-status .saved,
+.media-sidebar .save-error .settings-save-status .error {
 	display: block;
 }
 
Index: wp-includes/js/media-models.js
===================================================================
--- wp-includes/js/media-models.js	(revision 23028)
+++ wp-includes/js/media-models.js	(working copy)
@@ -219,7 +219,7 @@
 			// If the attachment does not yet have an `id`, return an instantly
 			// rejected promise. Otherwise, all of our requests will fail.
 			if ( _.isUndefined( this.id ) )
-				return $.Deferred().reject().promise();
+				return $.Deferred().rejectWith( this ).promise();
 
 			// Overload the `read` request so Attachment.fetch() functions correctly.
 			if ( 'read' === method ) {
@@ -233,8 +233,9 @@
 
 			// Overload the `update` request so properties can be saved.
 			} else if ( 'update' === method ) {
-				if ( ! this.get('nonces') )
-					return $.Deferred().resolveWith( this ).promise();
+				// If we do not have the necessary nonce, fail immeditately.
+				if ( ! this.get('nonces') || ! this.get('nonces').update )
+					return $.Deferred().rejectWith( this ).promise();
 
 				options = options || {};
 				options.context = this;
@@ -286,6 +287,10 @@
 		saveCompat: function( data, options ) {
 			var model = this;
 
+			// If we do not have the necessary nonce, fail immeditately.
+			if ( ! this.get('nonces') || ! this.get('nonces').update )
+				return $.Deferred().rejectWith( this ).promise();
+
 			return media.post( 'save-attachment-compat', _.defaults({
 				id:      this.id,
 				nonce:   this.get('nonces').update,
Index: wp-includes/js/media-views.js
===================================================================
--- wp-includes/js/media-views.js	(revision 23028)
+++ wp-includes/js/media-views.js	(working copy)
@@ -2731,8 +2731,7 @@
 		},
 
 		render: function() {
-			var attachment = this.model.toJSON(),
-				options = _.defaults( this.model.toJSON(), {
+			var options = _.defaults( this.model.toJSON(), {
 					orientation:   'landscape',
 					uploading:     false,
 					type:          '',
@@ -2754,6 +2753,12 @@
 			if ( 'image' === options.type )
 				options.size = this.imageSize();
 
+			options.can = {};
+			if ( options.nonces ) {
+				options.can.remove = !! options.nonces['delete'];
+				options.can.save = !! options.nonces.update;
+			}
+
 			this.views.detach();
 			this.$el.html( this.template( options ) );
 
@@ -2942,12 +2947,12 @@
 
 			this.updateSave('waiting');
 			save.requests = requests;
-			requests.done( function() {
+			requests.always( function() {
 				// If we've performed another request since this one, bail.
 				if ( save.requests !== requests )
 					return;
 
-				view.updateSave('complete');
+				view.updateSave( requests.state() === 'resolved' ? 'complete' : 'error' );
 				save.savedTimer = setTimeout( function() {
 					view.updateSave('ready');
 					delete save.savedTimer;
Index: wp-includes/media.php
===================================================================
--- wp-includes/media.php	(revision 23028)
+++ wp-includes/media.php	(working copy)
@@ -1334,11 +1334,17 @@
 		'icon'        => wp_mime_type_icon( $attachment->ID ),
 		'dateFormatted' => mysql2date( get_option('date_format'), $attachment->post_date ),
 		'nonces'      => array(
-			'update' => wp_create_nonce( 'update-post_' . $attachment->ID ),
-			'delete' => wp_create_nonce( 'delete-post_' . $attachment->ID ),
+			'update' => false,
+			'delete' => false,
 		),
 	);
 
+	if ( current_user_can( 'edit_post', $attachment->ID ) )
+		$response['nonces']['update'] = wp_create_nonce( 'update-post_' . $attachment->ID );
+
+	if ( current_user_can( 'delete_post', $attachment->ID ) )
+		$response['nonces']['delete'] = wp_create_nonce( 'delete-post_' . $attachment->ID );
+
 	if ( $meta && 'image' === $type ) {
 		$sizes = array();
 		$possible_sizes = apply_filters( 'image_size_names_choose', array(
@@ -1672,6 +1678,7 @@
 			<span class="settings-save-status">
 				<span class="spinner"></span>
 				<span class="saved"><?php esc_html_e('Saved.'); ?></span>
+				<span class="error"><?php esc_html_e('Error'); ?></span>
 			</span>
 		</h3>
 		<div class="attachment-info">
@@ -1690,7 +1697,7 @@
 				<# if ( 'image' === data.type && ! data.uploading && data.width && data.height ) { #>
 					<div class="dimensions">{{ data.width }} &times; {{ data.height }}</div>
 				<# } #>
-				<# if ( ! data.uploading ) { #>
+				<# if ( ! data.uploading && data.can.remove ) { #>
 					<div class="delete-attachment">
 						<a href="#"><?php _e( 'Delete Permanently' ); ?></a>
 					</div>
@@ -1703,25 +1710,27 @@
 			</div>
 		</div>
 
-		<# if ( 'image' === data.type ) { #>
+		<#
+		var maybeReadOnly = data.can.save ? '' : 'readonly';
+		if ( 'image' === data.type ) { #>
 			<label class="setting" data-setting="title">
 				<span><?php _e('Title'); ?></span>
-				<input type="text" value="{{ data.title }}" />
+				<input type="text" value="{{ data.title }}" {{ maybeReadOnly }} />
 			</label>
 			<label class="setting" data-setting="caption">
 				<span><?php _e('Caption'); ?></span>
-				<textarea
+				<textarea {{ maybeReadOnly }}
 					placeholder="<?php esc_attr_e('Describe this image&hellip;'); ?>"
 					>{{ data.caption }}</textarea>
 			</label>
 			<label class="setting" data-setting="alt">
 				<span><?php _e('Alt Text'); ?></span>
-				<input type="text" value="{{ data.alt }}" />
+				<input type="text" value="{{ data.alt }}" {{ maybeReadOnly }} />
 			</label>
 		<# } else { #>
 			<label class="setting" data-setting="title">
 				<span><?php _e('Title'); ?></span>
-				<input type="text" value="{{ data.title }}"
+				<input type="text" value="{{ data.title }}" {{ maybeReadOnly }}
 				<# if ( 'video' === data.type ) { #>
 					placeholder="<?php esc_attr_e('Describe this video&hellip;'); ?>"
 				<# } else if ( 'audio' === data.type ) { #>
